Compare commits

..

207 Commits

Author SHA1 Message Date
ashan_rusiru f053dbdd9d Merge pull request 'Dev' (#118) from Dev into production
Reviewed-on: #118
2026-10-21 11:10:56 +05:30
ashan_rusiru e6efc33ac0 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m43s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m56s
2026-10-21 11:09:57 +05:30
Sasanka a27bdaea64 sales fixers 2026-10-21 10:45:19 +05:30
Sasanka 47479fb4f2 bug fixers 10/20 2026-10-20 15:33:19 +05:30
Sasanka 0e423b806c Sms & Email 2026-10-20 10:45:03 +05:30
ashan_rusiru 5feb2ae28b Merge pull request 'Dev' (#117) from Dev into production
Reviewed-on: #117
2026-10-19 10:41:35 +05:30
ashan_rusiru a40e463e8b Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m5s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m34s
2026-10-19 10:41:29 +05:30
Dhananjaya99 15716a558c Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-19 10:41:02 +05:30
Dhananjaya99 e9334be01f fix: update AuthHex BaseUrl to correct development server address 2026-10-19 10:40:55 +05:30
ashan_rusiru 9a3ab2a4f6 Merge pull request 'Dev' (#116) from Dev into production
Reviewed-on: #116
2026-10-18 16:23:26 +05:30
ashan_rusiru 6e4d16dc88 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m45s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m14s
2026-10-18 16:23:18 +05:30
Dhananjaya99 a0793b35d3 Add migration to include AccruedExpensesCode in gl_account_config table
- Introduced a new column 'AccruedExpensesCode' of type varchar(20) to the 'gl_account_config' table.
- Added SQL command to update existing records with a default value of '2300' for the 'AccruedExpensesCode' where it is currently null.
2026-10-18 16:21:38 +05:30
Dhananjaya99 dcdb628647 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-18 14:49:34 +05:30
Dhananjaya99 ce35973b58 feat: Enhance Stock Ledger functionality with item details and filters
- Updated StockService to compute running balance on read and include item SKU, name, category, and brand in StockLedgerRowDto.
- Modified GetLedgerAsync method to accept categoryId and brandId parameters for filtering.
- Enhanced StockController to support new query parameters for ledger retrieval.
- Updated StockDtos to include additional item information in StockLedgerRowDto.
- Adjusted frontend Stock Ledger page to display item details and implement searchable item picker.
- Added tests for StockLedger to verify running balance calculations and filtering by category and brand.
- Updated appsettings for service URLs and modified login page navigation logic.
2026-10-18 14:49:26 +05:30
ashan_rusiru efab804b04 Merge pull request 'Dev' (#115) from Dev into production
Reviewed-on: #115
2026-10-18 12:50:58 +05:30
ashan_rusiru 8726531d94 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m41s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m8s
2026-10-18 12:50:52 +05:30
Dhananjaya99 7da8834ce9 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-18 12:46:57 +05:30
Dhananjaya99 e7b1f270a5 feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
2026-10-18 12:46:31 +05:30
ashan_rusiru 70e8193247 Merge pull request 'Dev' (#114) from Dev into production
Reviewed-on: #114
2026-10-17 18:25:55 +05:30
ashan_rusiru 8b61c0910f Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m44s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m19s
2026-10-17 18:25:49 +05:30
Dhananjaya99 c27db06880 feat: add PriceAdjustmentLine repository to PriceAdjustmentService and optimize pending item query 2026-10-17 18:24:57 +05:30
Sasanka e289b8430f ui fixers in 10/16 2026-10-16 14:34:28 +05:30
Dhananjaya99 379ad204bd Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-15 10:54:34 +05:30
Dhananjaya99 61330f3927 feat: implement user role status toggle and reset password functionality
- Added toggle functionality for user roles (activate/deactivate) in roles and users pages.
- Introduced a reset password page that validates and updates user passwords via a reset link.
- Enhanced user detail page with edit functionality for user information (display name, email, mobile number).
- Implemented sending reset link feature for users, with appropriate error handling and success notifications.
- Updated API endpoints for user management to support new functionalities.
- Improved UI components for better user experience during status updates and password changes.
2026-10-15 10:54:28 +05:30
ashan_rusiru 81695a1fda Merge pull request 'Dev' (#113) from Dev into production
Reviewed-on: #113
2026-10-14 19:14:48 +00:00
ashan_rusiru e33fe85bbb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m30s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 17m0s
2026-10-14 19:14:42 +00:00
Dhananjaya99 5ec3edc0c6 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-14 17:22:27 +05:30
Dhananjaya99 eb274e62c4 Remove sample trend data and update chart rendering logic for better handling of empty trends 2026-10-14 17:22:19 +05:30
ashan_rusiru 0a1ef66f68 Merge pull request 'Dev' (#112) from Dev into production
Reviewed-on: #112
2026-10-14 08:50:31 +00:00
ashan_rusiru 0ff6f33e9a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m23s
2026-10-14 08:50:21 +00:00
Dhananjaya99 fd019e8ee6 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-14 14:19:37 +05:30
Dhananjaya99 7fa871c449 Remove 'peer' field from several dependencies in package-lock.json 2026-10-14 14:19:25 +05:30
ashan_rusiru 6b87cd2d64 Merge pull request 'Dev' (#111) from Dev into production
Reviewed-on: #111
2026-10-14 07:55:17 +00:00
ashan_rusiru b78152b428 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m2s
2026-10-14 07:54:55 +00:00
Dhananjaya99 d926254419 Remove build.ps1 script and correct typo in page.tsx comment 2026-10-14 13:24:32 +05:30
Dhananjaya99 169a7c325c Update .dockerignore and .gitignore to include 'dist' directory; add build.ps1 script for frontend packaging 2026-10-14 13:21:27 +05:30
ImanThiyanga 24a8b1045c Merge pull request 'Dev' (#109) from Dev into production
Reviewed-on: #109
2026-10-14 05:46:44 +00:00
ImanThiyanga b3f3b45c27 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m36s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m7s
2026-10-14 05:46:37 +00:00
ImanThiyanga a1160e4f15 Merge pull request 'Fix/ui bug fixers rebase repair' (#108) from fix/ui-bug-fixers-rebase-repair into Dev
Reviewed-on: #108
2026-10-14 05:45:31 +00:00
ImanThiyanga 3be425284c Repair botched rebase resolutions from ui-bug-fixers
The rebase onto Dev resolved five conflicts; four were wrong. In the
service files Dev's constructor parameter lists won while the branch's
field declarations and constructor bodies stayed, so the bodies assigned
parameters that no longer existed.

- AdjustmentService: restore fifo, approvals, db and serialTracking
  parameters (Dev's glAccounts kept, configuration dropped).
- FifoCostingService: restore the LowStockTracker field, parameter and
  assignment.
- CountService: restore _serialTracking and _adjustments, and delete
  Dev's PostAdjustmentJournalAsync. Dev's call site did not survive the
  rebase, so the method was orphaned dead code referencing fields this
  class no longer has. The branch routes count variances through
  AdjustmentService.SubmitAsync, which posts via Dev's role-keyed
  glCodes, so nothing is lost by removing it.
- PurchaseInvoicePaymentService: this one compiled. paymentDay was
  computed but the GL period and EntryDate still used now, so a
  back-dated payment posted its journal to today's period. Use
  paymentDay, and resolve "today" through BusinessClock, matching
  SalesInvoicePaymentService.
- ErpDbContextModelSnapshot: the resolution dropped the whole Expense
  module from the definition and relationship sections while leaving its
  navigation entries behind, so the snapshot no longer loaded. Rebuilt as
  the branch's snapshot plus Dev's GlAccountConfig blocks; EF now reports
  no pending model changes.

Tests: BankDepositTestSupport's FakeGeneralLedger gains the
ListAccountsAsync member Dev added to IGeneralLedgerService, and
DualApprovalTests passes a StubGlAccountConfigService in place of the
removed IConfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 09:49:09 +05:30
Sasanka d0354ed169 price adjustment and stock adjustments 2026-10-12 15:02:59 +05:30
Sasanka a788349138 serial numbers 2026-10-12 14:57:15 +05:30
Sasanka 3abcfa6e6f ui bug fixers 2026-10-12 14:56:12 +05:30
ImanThiyanga 0d3f2fba14 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-12 14:36:01 +05:30
ImanThiyanga 1cddbaf84f Merge pull request 'Add GL account configuration and role mapping entities' (#107) from Dev into production
Reviewed-on: #107
2026-10-07 08:19:12 +00:00
ImanThiyanga c9906d24c9 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m3s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m7s
2026-10-07 08:19:03 +00:00
ImanThiyanga a3591d3643 Sync package-lock.json with package.json so npm ci works again
html-to-image and jspdf were added to package.json without being added to
the lockfile, so `npm ci` - which the frontend Dockerfile runs - failed
with the package.json/package-lock.json out-of-sync error. tsc also could
not resolve either module in SalesInvoicePrint.tsx.

Pre-existing and unrelated to the GL account work; found because the GL
Accounts screen could not be build-verified without it.

Adds those two plus their transitive deps. It also drops @emnapi/core and
@emnapi/runtime: both are optional, and required only by the wasm32-wasi /
sharp-wasm32 variants, which linux-x64 never selects in favour of the
native binaries - so the Docker build is unaffected.

Verified: npm ci succeeds, tsc --noEmit is clean, npm run build compiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 13:17:52 +05:30
ImanThiyanga c30ff473f5 Add the GL Accounts settings screen
Pickers are populated from GL's own chart of accounts, postable and active
accounts only, so a user can only choose a code the server will accept. A
stored code GL does not have is shown flagged and still selected rather
than appearing blank, which is the state 11 of the 12 mappings are in until
GL's chart is seeded - the broken mapping stays visible instead of looking
merely unset.

Options come from /gl-account-config rather than the GL proxy: that proxy
is gated on the `ledgers` nav permission while this screen is on
`settings`, and one endpoint keeps the picker and the server-side
validation working from the same list. Gated on the existing `settings` key
so no role_permissions edit is needed.

Saving is disabled outright while GL is unreachable, matching the server,
which refuses to store a mapping it could not verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 13:17:22 +05:30
ImanThiyanga c41aed9a80 Move the GL account mapping out of appsettings into validated master data
The per-module Gl*Code appsettings keys pinned GL's account *codes* in
ERPCore's config, while GL generates codes positionally and refuses a
requested one. Nothing connected the two, so "1100 means Inventory" held
only because someone once created accounts in a particular order - and on
2026-10-07 eleven of the twelve configured codes turned out never to have
existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had
been failing with "Account <code> not found".

The mapping is now master data in gl_account_config, keyed by accounting
role rather than by module, and GlAccountConfigService validates every
submitted code against GL's live chart of accounts before storing it. A
code GL does not have, or cannot post to, is refused with the role named -
so the mismatch is unrepresentable rather than merely detected. Correcting
a mapping no longer needs a redeploy.

Notes:
- Fails closed on save (503 if GL is unreachable), deliberately opposite to
  the startup validator, which must still let the app serve.
- Non-postable accounts are rejected too: GL rejects a journal line against
  one, so storing it only defers the failure. The auto-created 1000
  Bank/Cash header is exactly that trap.
- The settings endpoint also serves the selectable accounts, because
  GeneralLedgerController is gated on `ledgers` while this is on `settings`
  - and it keeps the picker and the validation list the same data.
- Reading an unmapped role throws naming the role, instead of posting ""
  and letting GL return an opaque 404 from inside a transaction.
- The appsettings keys are now seed-only: DataSeeder copies them in once so
  existing deployments keep their current mapping. No runtime fallback, or
  there would be two sources of truth again.
- The 32 read sites across 11 services collapsed to 12 roles, since every
  module carried the same value for a given role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 13:04:49 +05:30
ImanThiyanga 614df3065d Add GL account configuration and role mapping entities
- Introduced GlAccountConfig entity for mapping accounting roles to GL account codes.
- Added GlAccountConfigConfiguration for database configuration of the singleton GL account mapping.
- Created GlAccountRole enum to define accounting roles used in GL postings.
- Enhanced error handling with new error codes for unconfigured and invalid GL accounts.
- Updated existing DbContext to include GlAccountConfig DbSet.
2026-10-07 12:51:15 +05:30
ImanThiyanga 60652d611e Merge pull request 'Stop the GL account-code check from being able to abort startup' (#106) from Dev into production
Reviewed-on: #106
2026-10-07 06:40:46 +00:00
ImanThiyanga 65976c5cd3 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m15s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m33s
2026-10-07 06:40:39 +00:00
ImanThiyanga 3c8837e21e Stop the GL account-code check from being able to abort startup
The catch filter only matched DomainException/OperationCanceledException,
so anything else escaped a hosted service and took the host down. One such
path was mine: `"items": null` deserializes over the property initialiser,
so `data.Items.Select(...)` would NullReferenceException straight past the
filter.

Fixed at both ends - the reader tolerates a null or absent items array, and
the catch is now a deliberate catch-all that only lets a genuine host
shutdown through. This is a diagnostic; a diagnostic that can core-dump the
thing it diagnoses is worse than no diagnostic at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 11:53:31 +05:30
ImanThiyanga 63c8c8457f Merge pull request 'Make the GL account-code check report instead of refusing to start' (#105) from Dev into production
Reviewed-on: #105
2026-10-07 06:14:37 +00:00
ImanThiyanga e0601d5a8d Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m43s
2026-10-07 06:14:29 +00:00
ImanThiyanga 9eab6372b3 Make the GL account-code check report instead of refusing to start
The check shipped in f753cae threw on a missing code, which core-dumped
erpcore.service on deploy: 11 of the 12 configured Gl*Code values turned
out never to have existed in GL's chart of accounts, so a long-standing
mismatch became an immediate outage.

Detection was the point; refusing to serve the rest of the app was not.
GL already rejects a posting to an unknown account, so the per-flow error
stays the real enforcement — this now logs the whole set at Error once at
boot instead of surfacing one flow at a time.

Hard-fail is still available behind
GeneralLedgerService:FailOnMissingAccountCodes, for once the chart of
accounts is known-good and drift should stop a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 11:40:46 +05:30
ImanThiyanga 92f571bbf1 Merge pull request 'Refactor GL period and entry date handling to use BusinessClock for accurate date resolution; add GlAccountCodeValidator for startup validation of configured account codes.' (#104) from Dev into production
Reviewed-on: #104
2026-10-07 05:57:54 +00:00
ImanThiyanga b236b2b126 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m6s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m25s
2026-10-07 05:57:43 +00:00
ImanThiyanga f753cae82d Refactor GL period and entry date handling to use BusinessClock for accurate date resolution; add GlAccountCodeValidator for startup validation of configured account codes. 2026-10-07 11:27:12 +05:30
ImanThiyanga 533b642367 Merge pull request 'Fix MariaDB CHECK constraint quoting that crash-looped startup seeding' (#103) from Dev into production
Reviewed-on: #103
2026-10-05 13:02:54 +00:00
ImanThiyanga ea8c8210e5 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m4s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m25s
2026-10-05 13:02:47 +00:00
ImanThiyanga c97a7e9ae6 Fix MariaDB CHECK constraint quoting that crash-looped startup seeding
Three CHECK constraints shipped with PostgreSQL identifier quoting left over
from the MySQL port:

  ck_grn_config_singleton       CHECK ("ConfigId" = 1)
  ck_sales_config_singleton     CHECK ("ConfigId" = 1)
  ck_warranty_periods_months    CHECK ("Months" BETWEEN 1 AND 600)

ANSI_QUOTES is not in MariaDB's default sql_mode, so a double-quoted token is a
*string literal*, not an identifier. `"ConfigId" = 1` therefore evaluates as
`'ConfigId' = 1`; MariaDB coerces the string to a number to compare it and,
under strict mode, raises `Truncated incorrect DECIMAL value: 'ConfigId'`.

The DDL creates fine and only breaks at runtime, so this got through the
deploy and then failed every INSERT into grn_config, sales_config and
warranty_periods. That includes DataSeeder's two singleton config rows, which
run immediately after MigrateAsync() in Program.cs — so the service threw
"Database migration succeeded, but startup seeding failed" on every boot and
systemd restart-looped (counter reached 110, each restart dumping core).

Fixed in the entity configurations and repaired on existing databases by
migration 20261005190000_FixCheckConstraintQuotingForMySql, which drops and
re-adds the three constraints with backticks. The drop/add is emitted as raw
SQL rather than DropCheckConstraint because MariaDB spells the removal
DROP CONSTRAINT, not MySQL's DROP CHECK; IF EXISTS keeps it re-runnable on a
database where the broken constraint was already removed by hand.

The identical trap was swept during the port itself; these three were
reintroduced by a feature migration written afterwards.

No data cleanup is needed: the failing inserts never committed, so the
affected tables are empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:26:25 +05:30
ImanThiyanga 673ef06856 Merge pull request 'Dev' (#102) from Dev into production
Reviewed-on: #102
2026-10-05 12:11:38 +00:00
ImanThiyanga 16a382a8ea Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m25s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m42s
2026-10-05 12:11:30 +00:00
ImanThiyanga eae9dbb30d Merge pull request 'update on production db' (#101) from feat/mysql-migration into Dev
Reviewed-on: #101
2026-10-05 12:11:05 +00:00
ImanThiyanga 4efd5bda75 Merge branch 'Dev' into feat/mysql-migration 2026-10-05 12:10:58 +00:00
ImanThiyanga dbf9e841f8 update on production db 2026-10-05 17:40:04 +05:30
ImanThiyanga 0ad5a1a71d Merge pull request 'Dev' (#100) from Dev into production
Reviewed-on: #100
2026-10-05 11:47:07 +00:00
ImanThiyanga c1405da8d2 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m11s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m58s
2026-10-05 11:47:00 +00:00
ImanThiyanga cc83a3e59f Merge pull request 'Feat/mysql migration' (#99) from feat/mysql-migration into Dev
Reviewed-on: #99
2026-10-05 11:46:12 +00:00
ImanThiyanga 8706c2557b initiating db migration 2026-10-05 17:15:08 +05:30
ImanThiyanga 9503ea5fe8 fix after migrations 2026-10-05 17:14:42 +05:30
ImanThiyanga 65f58c4000 adding complete db migrations from posgres 2026-10-05 16:11:35 +05:30
ImanThiyanga 9d710349c2 Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls. 2026-10-05 16:03:31 +05:30
kalana_Thilakarathna 535616c48f Merge pull request 'Dev' (#98) from Dev into production
Reviewed-on: #98
2026-10-05 10:07:55 +00:00
kalana_Thilakarathna c6c9c6a9dd Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m53s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m25s
2026-10-05 10:07:50 +00:00
kalana_Thilakarathna dedb42365e Merge pull request 'Day end sales fixers' (#97) from day-end-sales-fixers into Dev
Reviewed-on: #97
2026-10-05 10:07:34 +00:00
Sasanka f4ec3b4c65 fixers of day end sales and sales 2026-10-05 13:11:01 +05:30
ashan_rusiru 6c707d88b1 Merge pull request 'sales & grn fixers' (#94) from sales-&-GRN-fixers into production
Reviewed-on: #94
2026-10-04 11:42:29 +00:00
ashan_rusiru 759c512281 Merge branch 'production' into sales-&-GRN-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m50s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m14s
2026-10-04 11:42:19 +00:00
Sasanka 3d3dbbbb59 sales & grn fixers 2026-10-04 15:31:59 +05:30
ashan_rusiru f4591a193a Merge pull request 'product and sales fixers' (#93) from product-and-sales-fixers into production
Reviewed-on: #93
2026-10-04 06:57:46 +00:00
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
Sasanka cea433e320 product and sales fixers 2026-09-30 17:06:28 +05:30
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
ImanThiyanga 9fd290e256 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m38s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m1s
2026-09-25 09:03:39 +00:00
ImanThiyanga 20ecfdc0b2 Merge pull request 'fixing recipts in sales' (#90) from sales-changes into Dev
Reviewed-on: #90
2026-09-25 09:03:21 +00:00
ImanThiyanga dd82f5b5c4 Merge branch 'Dev' into sales-changes 2026-09-25 09:03:15 +00:00
ImanThiyanga ce55d647b5 Merge pull request 'fron build failure fix' (#89) from Dev into production
Reviewed-on: #89
2026-09-25 06:58:55 +00:00
ImanThiyanga 268b6562dc Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m50s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m24s
2026-09-25 06:58:48 +00:00
ImanThiyanga b389c01dfe fron build failure fix 2026-09-25 12:28:20 +05:30
Sasanka d24072e20a fixing recipts in sales 2026-09-24 17:02:33 +05:30
ashan_rusiru 9185d39c39 Merge pull request 'Dev' (#88) from Dev into production
Reviewed-on: #88
2026-09-24 07:12:59 +00:00
ashan_rusiru a2790c13bb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m18s
2026-09-24 07:12:52 +00:00
ashan_rusiru 940583552a Merge pull request 'Ui fixers and responsivenes' (#87) from ui-fixers-and-responsivenes into Dev
Reviewed-on: #87
2026-09-24 07:09:52 +00:00
Sasanka 049c98298a Ui fixings 2026-09-24 12:30:21 +05:30
Sasanka f5e8e0e5b9 responsiveness and ui fixers 2026-09-23 16:55:46 +05:30
ashan_rusiru f35439e364 Merge pull request 'Dev' (#86) from Dev into production
Reviewed-on: #86
2026-09-21 19:03:14 +00:00
ashan_rusiru 4c5e9b347a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m41s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m5s
2026-09-21 19:03:08 +00:00
Dhananjaya99 fdd104284e feat: add ItemOverviewDto for enriched item data in items list
feat: implement CustomerDetailPage for detailed customer view and editing

feat: create CustomersPage for listing and managing customers with filters

feat: add ColumnsMenu component for customizable table columns visibility

feat: implement CSV export functionality for data download

feat: add customer code generation logic for unique customer identifiers
2026-09-22 00:32:40 +05:30
ImanThiyanga 72819e5f7b production change 2026-09-21 18:02:55 +05:30
kalana_Thilakarathna edd1011330 Merge pull request 'chore: update next.js version to ^16.3.5 in package.json' (#85) from Dev into production
Reviewed-on: #85
2026-09-20 12:41:34 +00:00
Kalana B. Thilakarathna 3ae14fad72 chore: update next.js version to ^16.3.5 in package.json
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m45s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m23s
2026-09-20 18:11:04 +05:30
kalana_Thilakarathna 84d3318f76 Merge pull request 'Dev' (#84) from Dev into production
Reviewed-on: #84
2026-09-20 12:27:45 +00:00
kalana_Thilakarathna bfb8e83a82 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m9s
2026-09-20 12:27:31 +00:00
kalana_Thilakarathna 40c1d6da27 Update Frontend/erp-system/Dockerfile 2026-09-20 12:26:57 +00:00
kalana_Thilakarathna 5aa709db17 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#83) from Dev into production
Reviewed-on: #83
2026-09-20 12:03:21 +00:00
kalana_Thilakarathna cf964a19a9 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 14m59s
2026-09-20 12:03:07 +00:00
kalana_Thilakarathna a40c6bf598 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#82) from Dev into production
Reviewed-on: #82
2026-09-20 11:57:04 +00:00
kalana_Thilakarathna f951700b58 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m16s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m55s
2026-09-20 11:56:50 +00:00
kalana_Thilakarathna b3939e0685 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#81) from Dev into production
Reviewed-on: #81
2026-09-20 11:50:14 +00:00
kalana_Thilakarathna af6f02023d Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m43s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-09-20 11:49:55 +00:00
kalana_Thilakarathna 9d569a1504 Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#80) from Dev into production
Reviewed-on: #80
2026-09-20 11:41:23 +00:00
kalana_Thilakarathna 5a44702cf3 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 10m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m56s
2026-09-20 11:41:10 +00:00
kalana_Thilakarathna 2a5dee3fef Merge pull request 'Update .gitea/workflows/ci-cd.yml' (#79) from Dev into production
Reviewed-on: #79
2026-09-20 11:34:43 +00:00
kalana_Thilakarathna 2584ab8715 Update .gitea/workflows/ci-cd.yml
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 12m8s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m36s
2026-09-20 11:34:29 +00:00
ImanThiyanga 6b9d191bf8 Merge pull request 'fixes on ci-cd' (#78) from Dev into production
Reviewed-on: #78
2026-09-18 19:32:33 +00:00
ImanThiyanga 283fa21e87 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 10m46s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m22s
2026-09-18 19:32:25 +00:00
ImanThiyanga 97d19d83ab fixes on ci-cd 2026-09-19 01:01:30 +05:30
ImanThiyanga 01d5159d27 Merge pull request 'changed front needs' (#77) from Dev into production
Reviewed-on: #77
2026-09-18 19:21:39 +00:00
ImanThiyanga 80899ed88c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 10m18s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m40s
2026-09-18 19:21:32 +00:00
ImanThiyanga cfe2466fec changed front needs 2026-09-19 00:51:11 +05:30
ImanThiyanga 9a124c8c63 Merge pull request 'fixed ci-cd' (#76) from Dev into production
Reviewed-on: #76
2026-09-18 19:05:55 +00:00
ImanThiyanga 42ab7e5964 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m25s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 19:05:48 +00:00
ImanThiyanga e7735135ca fixed ci-cd 2026-09-19 00:35:05 +05:30
ImanThiyanga f61d1f95b8 Merge pull request 'Dev' (#75) from Dev into production
Reviewed-on: #75
2026-09-18 18:54:54 +00:00
ImanThiyanga f632c5fc99 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m26s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 15m0s
2026-09-18 18:54:46 +00:00
ImanThiyanga 70179e1abc Merge pull request 'new updates' (#74) from new-updates into Dev
Reviewed-on: #74
2026-09-18 18:53:13 +00:00
Sasanka 8c3d62223c new updates 2026-09-19 00:22:53 +05:30
ImanThiyanga e1f19fe413 Merge pull request 'Add backup images for login and background removal' (#73) from UI into Dev
Reviewed-on: #73
2026-09-18 18:43:33 +00:00
Naveen cb6b92d221 Add backup images for login and background removal
- Added loginerps.png.bak (1134 KB) as a backup for the login image.
- Added whithoutbackground.png.bak (3455 KB) as a backup for the image without background.
2026-09-19 00:12:41 +05:30
ImanThiyanga 3f10ae3bca changes fix for production 2026-09-19 00:00:42 +05:30
ImanThiyanga 7274b89b28 Merge pull request 'migrations fix' (#72) from Dev into production
Reviewed-on: #72
2026-09-18 18:23:44 +00:00
ImanThiyanga 61fe1a0e0c Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Successful in 31s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 13m12s
2026-09-18 18:23:35 +00:00
ImanThiyanga 941f20d4c5 migrations fix 2026-09-18 23:52:26 +05:30
kalana_Thilakarathna b06d20e649 Merge pull request 'test to yml' (#71) from Dev into production
Reviewed-on: #71
2026-09-18 05:23:55 +00:00
Kalana B. Thilakarathna 445d323bed test to yml
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m24s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m59s
2026-09-18 10:52:17 +05:30
kalana_Thilakarathna 9028255800 Merge pull request 'workflowupdate' (#70) from Dev into production
Reviewed-on: #70
2026-09-18 04:51:58 +00:00
Kalana B. Thilakarathna 1b2f244853 workflowupdate
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 11m21s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m6s
2026-09-18 10:21:36 +05:30
kalana_Thilakarathna 475bcfec1a Merge pull request 'pipeline update' (#69) from Dev into production
Reviewed-on: #69
2026-09-18 04:48:49 +00:00
Kalana B. Thilakarathna 85db26ec82 pipeline update
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 14m30s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m58s
2026-09-18 10:18:13 +05:30
kalana_Thilakarathna baee987d26 Merge pull request 'test' (#68) from Dev into production
Reviewed-on: #68
2026-09-17 08:25:31 +00:00
Kalana B. Thilakarathna f45a59f20d test
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Failing after 14m57s
2026-09-17 13:55:06 +05:30
kalana_Thilakarathna 32304cbaff Merge pull request 'user fix at workflow' (#67) from Dev into production
Reviewed-on: #67
2026-09-17 07:35:02 +00:00
Kalana B. Thilakarathna 2b64bb0ebc user fix at workflow
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m17s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 13:04:15 +05:30
kalana_Thilakarathna ffe5251236 Merge pull request 'workflow fix' (#66) from Dev into production
Reviewed-on: #66
2026-09-17 07:26:14 +00:00
Kalana B. Thilakarathna d04a7fb3bc workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Has been cancelled
2026-09-17 12:55:38 +05:30
kalana_Thilakarathna d76595085e Merge pull request 'workflow fix' (#65) from Dev into production
Reviewed-on: #65
2026-09-17 07:08:22 +00:00
Kalana B. Thilakarathna bad33a8492 workflow fix
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Backend (pull_request) Failing after 13m57s
CI/CD - ERP Core Monorepo / Build & Deploy ERP Core Frontend (pull_request) Has been cancelled
2026-09-17 12:38:05 +05:30
kalana_Thilakarathna b0216fca30 Merge pull request 'triger erpo core workflow' (#64) from Dev into production
Reviewed-on: #64
2026-09-17 07:07:24 +00:00
Kalana B. Thilakarathna 49c112d188 triger erpo core workflow 2026-09-17 12:37:04 +05:30
kalana_Thilakarathna c836b57d54 Merge pull request 'gitea work flow update' (#63) from Dev into production
Reviewed-on: #63
2026-09-17 07:06:02 +00:00
Kalana B. Thilakarathna 3a583f80e5 gitea work flow update 2026-09-17 12:28:31 +05:30
ImanThiyanga 83e0335504 Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
2026-09-17 05:58:25 +00:00
ImanThiyanga 513343b62a Merge pull request 'Revert/ci cd merges' (#61) from revert/ci-cd-merges into Dev
Reviewed-on: #61
2026-09-17 05:57:56 +00:00
ImanThiyanga 256e39ccff Revert "Merge pull request 'Dev' (#58) from Dev into production"
This reverts commit 821e96e011, reversing
changes made to 0ff39e9e58.
2026-09-17 11:25:33 +05:30
ImanThiyanga efb37fce7a Revert "Merge pull request 'Dev' (#60) from Dev into production"
This reverts commit 01fc2582b6, reversing
changes made to 821e96e011.
2026-09-17 11:25:33 +05:30
ImanThiyanga 01fc2582b6 Merge pull request 'Dev' (#60) from Dev into production
Reviewed-on: #60
2026-09-16 18:47:15 +00:00
ImanThiyanga 39a2dc60be Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
2026-09-16 18:30:42 +00:00
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga 821e96e011 Merge pull request 'Dev' (#58) from Dev into production
Reviewed-on: #58
2026-09-16 06:37:25 +00:00
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
ImanThiyanga b3b6a25ce8 Merge pull request 'Branch crud' (#52) from branch-crud into Dev
Reviewed-on: #52
2026-09-08 07:13:47 +00:00
Sasanka f664961fce branch crud and intergration 2026-09-08 12:13:49 +05:30
Sasanka bebec10eb3 feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices.
- Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout.
- Added invoice layout presets for different styles (Classic, Compact, Modern).
- Developed PurchaseInvoicePaymentDialog for handling payments against invoices.
- Introduced API mocks for company profile and invoice template configuration.
- Added validation functions for company profile and purchase invoices.
- Defined types for company profile, invoice template configuration, and purchase invoices.
2026-09-01 15:25:32 +05:30
ashan_rusiru 160c687db3 Merge pull request 'notification module' (#51) from feat/notification-module into Dev
Reviewed-on: #51
2026-08-21 06:40:40 +00:00
Dhananjaya99 d413ad4064 notification module 2026-08-21 12:05:47 +05:30
ImanThiyanga 9b8c2493ad Merge pull request 'feat: add production navigation permissions and produced items page' (#50) from fix/production-costing into Dev
Reviewed-on: #50
2026-08-21 06:32:26 +00:00
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
Dhananjaya99 4cc6a546c7 add day-end and change item model 2026-08-20 14:10:39 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 9a38cc1131 Refactor code structure for improved readability and maintainability 2026-08-18 11:54:16 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
ImanThiyanga efa4ad3770 Merge pull request 'Feat/intigrate account service in grn module' (#47) from feat/intigrateAccountServiceInGrnModule into Dev
Reviewed-on: #47
2026-08-17 08:47:24 +00:00
Dhananjaya99 6528fc8d9d intigrate others sales , return and implement day end duntinalities 2026-08-16 23:21:40 +05:30
Dhananjaya99 ae6a87022d make account service with grn 2026-08-15 15:43:01 +05:30
ImanThiyanga ee9fa40edf Merge pull request 'initiated migrations fix in warranty' (#46) from fix/migrations-warrenty into Dev
Reviewed-on: #46
2026-08-13 09:42:02 +00:00
Sasanka b1b0fe4bac initiated migrations fix in warranty 2026-08-13 14:52:11 +05:30
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
ImanThiyanga 8555702a76 Merge branch 'production' into Dev 2026-08-13 06:52:48 +00:00
ImanThiyanga fd95e92eb1 Merge pull request 'feat: add warranty tracking for items and GRN lines' (#44) from warranty-fileds-and-ui-updates into Dev
Reviewed-on: #44
2026-08-13 06:51:10 +00:00
ImanThiyanga 6e008773db Merge pull request 'feat: add warranty tracking for items and GRN lines' (#43) from warranty-fileds-and-ui-updates into production
Reviewed-on: #43
2026-08-13 06:42:51 +00:00
Sasanka 179d5b0803 feat: add warranty tracking for items and GRN lines
- Enhanced ItemService to include warranty and warranty period months in item DTOs.
- Implemented validation for warranty periods in ItemService.
- Updated frontend item detail and new item pages to handle warranty selection and input.
- Added warranty number handling in GRN creation and detail pages.
- Introduced new GrnLineWarrantyNumber entity to capture warranty numbers for received items.
- Created Warranty enum and associated allowed months for warranty coverage.
- Updated validation logic for GRN lines to ensure warranty numbers are captured correctly.
2026-08-13 12:01:11 +05:30
ImanThiyanga 342012a321 Merge pull request 'Dev' (#42) from Dev into production
Reviewed-on: #42
2026-08-12 19:45:10 +00:00
ImanThiyanga ee6ac913f1 Merge branch 'production' into Dev 2026-08-12 19:44:58 +00:00
ImanThiyanga 2661169351 Merge pull request 'pushing migration changes from now -on' (#41) from fix/migration-issue into Dev
Reviewed-on: #41
2026-08-12 07:05:09 +00:00
ImanThiyanga 80213cf47d pushing migration changes from now -on 2026-08-12 12:32:25 +05:30
ImanThiyanga 18475fdc9f Merge pull request 'feat: add sales return functionality' (#40) from sales-return into Dev
Reviewed-on: #40
2026-08-12 06:28:09 +00:00
ImanThiyanga 9a32c5c609 Merge branch 'Dev' into sales-return 2026-08-12 06:26:49 +00:00
ImanThiyanga 32f40e9d1a Merge pull request 'sku and item fix' (#39) from fix/uom-overall into Dev
Reviewed-on: #39
2026-08-12 05:32:49 +00:00
Sasanka d16a227b54 feat: add sales return functionality
- Introduced Sales Return types and interfaces in the frontend for managing sales returns.
- Implemented SalesReturnsController in the backend to handle sales return endpoints.
- Created SalesReturn and SalesReturnLine entities to represent sales return data.
- Added DTOs for sales return responses and requests.
- Configured Entity Framework for SalesReturn and SalesReturnLine entities.
- Developed ISalesReturnService interface and its implementation for business logic.
- Added API methods for listing sales returns, creating new returns, and fetching remaining returnable quantities.
- Created frontend components for creating and listing sales returns, including validation logic.
- Implemented UI for selecting invoices, reason codes, and managing return lines.
2026-08-12 10:18:56 +05:30
ImanThiyanga 7219480ca0 Merge pull request 'Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev' (#31) from Dev into production
Reviewed-on: #31
2026-08-05 07:22:32 +00:00
945 changed files with 375716 additions and 12174 deletions
+106
View File
@@ -0,0 +1,106 @@
name: CI/CD - ERP Core Monorepo
on:
pull_request:
branches:
- production
types:
- closed
jobs:
build-and-deploy-backend:
name: Build & Deploy Backend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Restore
run: dotnet restore Backend/ERPCore.sln
- name: Publish
run: |
rm -rf /tmp/erpcore-deploy
mkdir -p /tmp/erpcore-deploy
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /tmp/erpcore-deploy
- name: Stop service
run: systemctl stop erpcore.service
- name: Copy to deploy folder
run: |
mkdir -p /home/erp/core/publish
rm -rf /home/erp/core/publish/*
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
- name: Fix ownership and permissions
run: |
chown -R erpuser:erpuser /home/erp/core/publish
find /home/erp/core/publish -type d -exec chmod 755 {} +
find /home/erp/core/publish -type f -exec chmod 644 {} +
chmod +x /home/erp/core/publish/ERPCore
- name: Restart service
# ConnectionStrings__DefaultConnection is supplied by the systemd unit on the host
# (Environment= or EnvironmentFile=), deliberately not committed: appsettings.json ships
# the key empty and appsettings.Production.json carries no ConnectionStrings section at
# all. If the unit does not set it, startup throws before serving and the Verify step
# below fails the job.
run: systemctl restart erpcore.service
- name: Verify
run: |
# `is-active ... || echo` always exited 0, so a crashed service still reported success.
# Wait a few seconds first: a startup failure (bad or missing connection string) happens
# during MigrateAsync, after systemctl restart has already returned.
sleep 5
if ! systemctl is-active --quiet erpcore.service; then
echo "❌ erpcore.service is not running:"
journalctl -u erpcore.service -n 50 --no-pager
exit 1
fi
echo "✅ Service running"
build-and-deploy-frontend:
name: Build & Deploy Frontend
runs-on: linux
if: github.event.pull_request.merged == true
steps:
- name: Checkout code
run: |
git clone \
--depth 1 \
--branch production \
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
.
- name: Build Docker image
run: |
docker build --progress=plain --no-cache \
-t erpcore-frontend:latest \
./Frontend/erp-system
- name: Stop & remove existing container
run: |
docker stop erpcore-frontend 2>/dev/null || true
docker rm erpcore-frontend 2>/dev/null || true
- name: Start container
run: |
docker run -d \
--name erpcore-frontend \
-p 6702:6702 \
--add-host=host.docker.internal:host-gateway \
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
--restart unless-stopped \
--memory="512m" \
--cpus="1.0" \
--cap-drop ALL \
--security-opt no-new-privileges \
erpcore-frontend:latest
- name: Remove old images
run: docker image prune -f
- name: Verify
run: |
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
+1 -4
View File
@@ -36,7 +36,4 @@ Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── 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/
+512
View File
@@ -0,0 +1,512 @@
using System.Security.Claims;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The ERP-owned role model: AuthHex proves who someone is; the role, its permissions and "is admin" come from the
/// ERP's own users/roles tables, are read on every request, and a change applies on the very next one.
/// </summary>
public class AccessControlTests
{
private const int AdminRole = 1, ApproverRole = 2, ViewerRole = 3, NoAccessRole = 4, InactiveRole = 5;
// ---------------------------------------------------------------- helpers
private sealed class FakeUser(int userId = 10, int? roleId = null, bool isAdmin = false) : ICurrentUser
{
public int AuditUserId { get; set; } = userId;
public int? RoleId { get; set; } = roleId;
public string? RoleCode { get; set; }
public bool IsAdmin { get; set; } = isAdmin;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
public int? BranchId => null;
}
private static async Task<ErpDbContext> SeedAsync(bool withDefault = true)
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = AdminRole, Code = "Admin", Name = "Administrator", IsAdmin = true, IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = ApproverRole, Code = "Approver", Name = "Approver", CreatedAt = DateTime.UtcNow },
new Role { RoleId = ViewerRole, Code = "Viewer", Name = "Viewer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = NoAccessRole, Code = "NoAccess", Name = "No access", IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = InactiveRole, Code = "Retired", Name = "Retired", Status = EntityStatus.Inactive, CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "procurement", Label = "Procurement" });
db.SubNavItems.Add(new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "procurement.vendors", Label = "Vendors" });
db.NavItems.Add(new NavItem { NavItemId = 102, Code = "stock", Label = "Stock" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:procurement", NavItemId = 100 },
new Permission { PermissionId = 201, Code = "NAV:procurement.vendors", SubNavItemId = 101 },
new Permission { PermissionId = 202, Code = "NAV:stock", NavItemId = 102 });
db.RolePermissions.AddRange(
new RolePermission { RoleId = ApproverRole, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Approve },
new RolePermission { RoleId = ViewerRole, PermissionId = 201, Actions = RolePermissionAction.View },
new RolePermission { RoleId = InactiveRole, PermissionId = 202, Actions = RolePermissionAction.All });
if (withDefault) db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = NoAccessRole });
await db.SaveChangesAsync();
return db;
}
private static ClaimsPrincipal Principal(Guid authUserId, string? tokenRoleCode = null)
{
var claims = new List<Claim> { new("UserId", authUserId.ToString()), new("NIC", "N-1") };
if (tokenRoleCode is not null) claims.Add(new Claim("RoleCode", tokenRoleCode));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ShadowUserClaimsTransformation Transformer(ErpDbContext db, bool legacyFallback = true) =>
new(db, new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:LegacyRoleClaimFallback"] = legacyFallback.ToString() })
.Build());
private static string? Claim(ClaimsPrincipal p, string type) => p.FindFirstValue(type);
private static User NewUser(Guid authId, int? roleId, EntityStatus status = EntityStatus.Active, int id = 0) => new()
{
UserId = id == 0 ? Random.Shared.Next(100, 100000) : id,
AuthUserId = authId, Username = $"u-{authId:N}", DisplayName = "Someone", RoleId = roleId, Status = status
};
private static NavPermissionService NavService(ErpDbContext db) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions()));
private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new Repository<Permission>(db), new Repository<Employee>(db),
new Repository<User>(db), new Repository<AccessConfig>(db), user, nav, new FakeBranchConfig(), new PassThroughUnitOfWork(db));
// The AuthHex client, link and employee services are not touched by role/status changes (users here have no AuthUserId).
private static UserManagementService UserSvc(ErpDbContext db, ICurrentUser user) =>
new(new Repository<User>(db), new Repository<Role>(db), new Repository<Employee>(db), null!, null!, null!, user, new PassThroughUnitOfWork(db));
// ---------------------------------------------------------------- claims: the role comes from the ERP
[Fact]
public async Task Role_IsReadFromTheErpTable_NotFromTheTokensRoleClaim()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ApproverRole));
await db.SaveChangesAsync();
// The token still says "Admin" (an old AuthHex token). The ERP says Approver, and the ERP wins.
var principal = await Transformer(db).TransformAsync(Principal(auth, tokenRoleCode: "Admin"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("Approver", Claim(principal, AuthHexClaims.ErpRoleCode));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task Admin_IsTheRoleFlag_NotTheRoleName()
{
await using var db = await SeedAsync();
var boss = Guid.NewGuid();
var impostor = Guid.NewGuid();
db.Roles.Add(new Role { RoleId = 20, Code = "Boss", Name = "The boss", IsAdmin = true, CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 21, Code = "ADMIN2", Name = "admin", CreatedAt = DateTime.UtcNow }); // named like an admin, is not
db.Users.AddRange(NewUser(boss, 20), NewUser(impostor, 21));
await db.SaveChangesAsync();
Assert.Equal("true", Claim(await Transformer(db).TransformAsync(Principal(boss)), AuthHexClaims.ErpIsAdmin));
Assert.Equal("false", Claim(await Transformer(db).TransformAsync(Principal(impostor)), AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task RenamingTheAdminRole_DoesNotCostAnyoneTheirAdminRights()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole));
await db.SaveChangesAsync();
db.Roles.Single(r => r.RoleId == AdminRole).Code = "Owners";
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("true", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal("Owners", Claim(principal, AuthHexClaims.ErpRoleCode));
}
[Fact]
public async Task FirstSignIn_CreatesTheUser_AndGivesItTheDefaultRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var principal = await Transformer(db).TransformAsync(Principal(auth));
var user = Assert.Single(db.Users.Where(u => u.AuthUserId == auth));
Assert.Equal(NoAccessRole, user.RoleId); // saved, so the Users screen shows it
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal(user.UserId.ToString(), Claim(principal, ClaimTypes.NameIdentifier));
}
[Fact]
public async Task WithNoDefaultRole_APersonWithoutARoleHasNoRoleClaims()
{
await using var db = await SeedAsync(withDefault: false);
var principal = await Transformer(db).TransformAsync(Principal(Guid.NewGuid()));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.NotNull(Claim(principal, ClaimTypes.NameIdentifier)); // still a known user
}
[Fact]
public async Task LegacyFallback_UsesTheTokensRole_ForARolelessUser_AndSavesIt()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: true).TransformAsync(Principal(auth, "Approver"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ApproverRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task LegacyFallbackOff_IgnoresTheTokenAndUsesTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: false).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task AnAssignedRole_IsNeverOverwrittenByTheFallbackOrTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ViewerRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(ViewerRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ViewerRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task InactiveUser_IsBlocked_AndGetsNoRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole, EntityStatus.Inactive));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("1", Claim(principal, AuthHexClaims.ErpBlocked));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task InactiveRole_GrantsNothing()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, InactiveRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpBlocked)); // not blocked — just no permissions
}
[Fact]
public async Task Branch_ComesFromTheLinkedEmployee_AndTransformingTwiceAddsNothingNew()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 500);
db.Users.Add(user);
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E1", FullName = "Emp", UserId = 500, BranchId = Branch2 });
await db.SaveChangesAsync();
var transformer = Transformer(db);
var principal = await transformer.TransformAsync(Principal(auth));
var claimsAfterFirst = principal.Claims.Count();
principal = await transformer.TransformAsync(principal);
Assert.Equal(Branch2.ToString(), Claim(principal, AuthHexClaims.BranchId));
Assert.Equal(claimsAfterFirst, principal.Claims.Count());
}
// ---------------------------------------------------------------- permissions by role id
[Fact]
public async Task Nav_AccessAndActions_AreDecidedByTheRolesPermissions()
{
await using var db = await SeedAsync();
var nav = NavService(db);
Assert.True(await nav.HasAccessAsync(ApproverRole, "procurement"));
Assert.True(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Approve));
Assert.False(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Delete));
Assert.False(await nav.HasAccessAsync(ApproverRole, "stock"));
// A parent module is reachable through a child grant; an action is exact.
Assert.True(await nav.HasAccessAsync(ViewerRole, "procurement"));
Assert.False(await nav.HasActionAsync(ViewerRole, "procurement", RolePermissionAction.View));
Assert.True(await nav.HasActionAsync(ViewerRole, "procurement.vendors", RolePermissionAction.View));
Assert.False(await nav.HasAccessAsync(null, "procurement"));
Assert.False(await nav.HasAccessAsync(999, "procurement"));
}
[Fact]
public async Task Nav_InactiveRole_HasNoAccess()
{
await using var db = await SeedAsync();
Assert.False(await NavService(db).HasAccessAsync(InactiveRole, "stock"));
}
[Fact]
public async Task EditingARolesPermissions_AppliesImmediately_NotAfterACacheTimeout()
{
await using var db = await SeedAsync();
var nav = NavService(db);
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), nav);
Assert.False(await nav.HasAccessAsync(ViewerRole, "stock")); // cached as "no"
await roles.AssignPermissionsAsync(ViewerRole, new Dtos.Rbac.AssignRolePermissionsRequest { NavItemIds = [102] });
Assert.True(await nav.HasAccessAsync(ViewerRole, "stock")); // visible straight away
}
// ---------------------------------------------------------------- /auth/me
[Fact]
public async Task Me_ForAnAdministrator_ListsEveryPermission_EvenOnesNeverGranted()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.True(me.IsAdmin);
Assert.Equal(AdminRole, me.RoleId);
Assert.Contains("procurement.vendors", me.NavCodes);
Assert.Contains("stock", me.NavCodes);
Assert.Equal((int)RolePermissionAction.All, me.Permissions!["stock"]);
}
[Fact]
public async Task Me_ForAnOrdinaryRole_ListsOnlyItsGrants_WithTheirActions()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: ApproverRole), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.False(me.IsAdmin);
Assert.Equal(["procurement"], me.NavCodes);
Assert.Equal((int)(RolePermissionAction.View | RolePermissionAction.Approve), me.Permissions!["procurement"]);
}
[Fact]
public async Task Me_WithoutARole_SaysSo_InsteadOfReturningAnEmptyMenu()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: null), NavService(db)).GetMeAsync();
Assert.False(me.HasRole);
Assert.Empty(me.NavCodes);
Assert.Empty(me.Permissions!);
}
// ---------------------------------------------------------------- role management guards
[Fact]
public async Task TheAdministratorSystemAndDefaultRoles_CannotBeDeactivatedOrDeleted()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(AdminRole));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(NoAccessRole)); // system role and the default
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(NoAccessRole, EntityStatus.Inactive));
}
[Fact]
public async Task ARoleInUse_CannotBeDeactivated_ButAnUnusedOneCanBeDeleted()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole));
await db.SaveChangesAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(ViewerRole, EntityStatus.Inactive));
await roles.DeleteAsync(ApproverRole);
Assert.False(db.Roles.Any(r => r.RoleId == ApproverRole));
}
[Fact]
public async Task CreatedRoles_NeverCarryTheAdminFlag()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
var created = (await roles.CreateAsync(new Dtos.Rbac.CreateRoleRequest { Code = "Clerk", Name = "Clerk" })).Value;
Assert.False(created.IsAdmin);
Assert.False(created.IsSystemRole);
Assert.Null(db.Roles.Single(r => r.RoleId == created.RoleId).AuthRoleId);
}
// ---------------------------------------------------------------- user management guards
[Fact]
public async Task ChangingAUsersRole_TakesEffectOnTheirNextRequest_WithNoRelogin()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 700);
db.Users.Add(user);
await db.SaveChangesAsync();
Assert.Equal(ViewerRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
await UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(700, new UpdateUserRoleRequest { RoleId = ApproverRole });
// A fresh request for the same login now carries the new role.
Assert.Equal(ApproverRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
}
[Fact]
public async Task TheLastActiveAdministrator_CannotBeDemotedOrDeactivated()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), AdminRole, id: 801));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 1, roleId: AdminRole, isAdmin: true));
var demote = await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
var deactivate = await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
Assert.Equal(409, demote.StatusCode);
Assert.Equal(409, deactivate.StatusCode);
Assert.Equal(AdminRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task AnAdministrator_CanBeDemoted_WhenAnotherActiveAdminRemains()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
await UserSvc(db, new FakeUser(userId: 802, roleId: AdminRole, isAdmin: true))
.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole });
Assert.Equal(ViewerRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task Nobody_CanRemoveTheirOwnAdminAccess_OrDeactivateThemselves()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 801, roleId: AdminRole, isAdmin: true));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
}
[Fact]
public async Task AUser_CannotBeGivenAnInactiveRole()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole, id: 900));
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(
() => UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(900, new UpdateUserRoleRequest { RoleId = InactiveRole }));
}
// ---------------------------------------------------------------- access config (default role)
private static AccessConfigService AccessSvc(ErpDbContext db) =>
new(new Repository<AccessConfig>(db), new Repository<Role>(db), new FakeUser(), new PassThroughUnitOfWork(db));
[Fact]
public async Task TheDefaultRole_CanNeverBeAnAdministratorOrAnInactiveRole()
{
await using var db = await SeedAsync();
var svc = AccessSvc(db);
var etag = db.AccessConfig.Single().RowVersion;
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag));
await Assert.ThrowsAsync<NotFoundException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = 999 }, etag));
Assert.Equal(NoAccessRole, db.AccessConfig.Single().DefaultRoleId);
var changed = (await svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = ViewerRole }, etag)).Value;
Assert.Equal(("Viewer", ViewerRole), (changed.DefaultRoleName, changed.DefaultRoleId));
}
// ---------------------------------------------------------------- seeding
[Fact]
public async Task Seeder_EnsuresAnAdminRoleAndALeastPrivilegeDefault_AndIsIdempotent()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
await DataSeeder.SeedAsync(db);
await DataSeeder.SeedAsync(db);
var admin = Assert.Single(db.Roles.Where(r => r.IsAdmin));
Assert.True(admin.IsSystemRole);
Assert.Equal(db.Permissions.Count(), db.RolePermissions.Count(rp => rp.RoleId == admin.RoleId));
var noAccess = Assert.Single(db.Roles.Where(r => r.Code == DataSeeder.DefaultNoAccessRoleCode));
Assert.Empty(db.RolePermissions.Where(rp => rp.RoleId == noAccess.RoleId));
Assert.Equal(noAccess.RoleId, Assert.Single(db.AccessConfig).DefaultRoleId);
}
[Fact]
public async Task Seeder_FlagsAnExistingRoleCalledAdmin_InsteadOfAddingASecond_AndKeepsAChosenDefault()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
db.Roles.Add(new Role { RoleId = 50, Code = "ADMIN", Name = "Old admin", CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 51, Code = "Reader", Name = "Reader", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
db.AccessConfig.Single().DefaultRoleId = 51; // an administrator's own choice
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
Assert.True(db.Roles.Single(r => r.RoleId == 50).IsAdmin);
Assert.Equal(1, db.Roles.Count(r => r.IsAdmin));
Assert.Equal(51, db.AccessConfig.Single().DefaultRoleId);
}
}
@@ -0,0 +1,211 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>Recording and cancelling bank deposits (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2, §11).</summary>
public class BankDepositServiceTests
{
private static readonly DateOnly Today = Clock.Today;
private static readonly DateOnly Start = Today.AddDays(-10);
private static readonly DateOnly Day = Today.AddDays(-2);
[Fact]
public async Task Create_PostsBankDebitCashCredit_AndLowersCashInHand()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var dto = await DepositService(db, Start, gl).CreateAsync(Request(600, depositDate: Day.AddDays(1)));
Assert.Equal(BankDepositStatus.Posted, dto.Status);
Assert.Equal(("BOC Current", "Bank of Ceylon", "0001234567"), (dto.ToBankAccountName, dto.ToBankName, dto.ToAccountNumber));
Assert.Equal("JE-1", dto.GlJournalNo);
var entry = Assert.Single(gl.Posted);
Assert.Equal(Day.AddDays(1), entry.EntryDate);
Assert.Equal("BANK_DEPOSIT", entry.SourceModule);
Assert.Collection(entry.Lines,
l => Assert.Equal(("1010-01", 600m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("1000-01", 0m, 600m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
var position = await CashService(db, Start).GetPositionAsync(Day, Branch1);
Assert.Equal((600m, 400m), (position.Position.Deposited, position.Position.Closing));
}
[Fact]
public async Task Create_MoreThanCashInHand_IsRejected()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000, card: 5000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, gl).CreateAsync(Request(1000.01m)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
Assert.Empty(gl.Posted);
}
[Fact]
public async Task Create_BackDatedDepositThatOverdrawsALaterDay_IsRejected()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
db.BankDeposits.Add(Deposit(Day.AddDays(1), 900)); // the next day already banked most of it
await db.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(500)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
Assert.Contains("100.00", ex.Message);
}
[Fact]
public async Task Cancel_ReversesGl_AndPutsTheCashBack()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var gl = new FakeGeneralLedger();
var service = DepositService(db, Start, gl);
var created = await service.CreateAsync(Request(1000));
var cancelled = await service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = " wrong bank " });
Assert.Equal(BankDepositStatus.Cancelled, cancelled.Status);
Assert.Equal("wrong bank", cancelled.CancelReason);
Assert.Equal("JE-2", cancelled.GlReversalJournalNo);
Assert.Collection(gl.Posted[1].Lines,
l => Assert.Equal(("1000-01", 1000m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("1010-01", 0m, 1000m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(1000m, (await CashService(db, Start).GetPositionAsync(Day, Branch1)).Position.Closing);
var again = await Assert.ThrowsAsync<DomainException>(() =>
service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = "again" }));
Assert.Equal(ErrorCodes.BankDepositNotCancellable, again.Code);
}
[Theory]
[InlineData(FakeGeneralLedger.BankAccountId, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
[InlineData(FakeGeneralLedger.CashAccountId, FakeGeneralLedger.CashAccountId, ErrorCodes.BankDepositBankAccountInvalid)]
[InlineData(99L, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
public async Task Create_WrongAccountTypes_AreRejected(long from, long to, string code)
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
var request = Request(100);
request.FromGlCashAccountId = from;
request.ToGlBankAccountId = to;
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
Assert.Equal(code, ex.Code);
}
[Fact]
public async Task Create_CashAndBankAccountsSharingAnId_AreToldApartByType()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
// GL numbers cash and bank accounts in separate tables, so both can be account 1.
var gl = new FakeGeneralLedger
{
Accounts =
[
new("Cash", 1, "main", null, "Cash in Transit", "CASH-000001", 22, "1400-01-01", "LKR"),
new("Bank", 1, "Main - 01", "Commercial Bank", null, "2032434543", 2, "1000-01", "LKR")
]
};
var request = Request(400);
request.FromGlCashAccountId = 1;
request.ToGlBankAccountId = 1;
var dto = await DepositService(db, Start, gl).CreateAsync(request);
Assert.Equal(("main", "Main - 01"), (dto.FromCashAccountName, dto.ToBankAccountName));
Assert.Collection(Assert.Single(gl.Posted).Lines,
l => Assert.Equal(("1000-01", 400m), (l.AccountCode, l.DebitAmount)),
l => Assert.Equal(("1400-01-01", 400m), (l.AccountCode, l.CreditAmount)));
}
[Fact]
public async Task Create_CannotUseAnotherBranchsCash()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000, warehouseId: Warehouse2));
await db.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(100, branchId: Branch1)));
Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
}
[Theory]
[InlineData(-11, null)] // before tracking starts
[InlineData(1, null)] // business date in the future
[InlineData(-2, -3)] // deposited before the business day
[InlineData(-2, 2)] // deposit date beyond tomorrow
public async Task Create_InvalidDates_AreRejected(int businessOffset, int? depositOffset)
{
await using var db = CreateContext();
var request = Request(100);
request.BusinessDate = Today.AddDays(businessOffset);
request.DepositDate = depositOffset is { } d ? Today.AddDays(d) : null;
var ex = await Assert.ThrowsAsync<DomainException>(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
Assert.Equal(ErrorCodes.BankDepositDateInvalid, ex.Code);
}
[Fact]
public async Task Create_GlRejection_LeavesNoDeposit()
{
await using var db = CreateContext();
db.SalesSlips.Add(CashSlip(Day, 1000));
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(() =>
DepositService(db, Start, new FakeGeneralLedger { Reject = true }).CreateAsync(Request(500)));
Assert.Equal(0, await db.BankDeposits.CountAsync());
}
[Fact]
public async Task List_FiltersAndTotalsEveryMatchingRow()
{
await using var db = CreateContext();
db.BankDeposits.AddRange(
Deposit(Day, 100), Deposit(Day, 200), Deposit(Day.AddDays(-1), 400),
Deposit(Day, 800, branchId: Branch2), Deposit(Day, 1600, status: BankDepositStatus.Cancelled));
await db.SaveChangesAsync();
var result = await DepositService(db, Start, new FakeGeneralLedger()).ListAsync(
new() { PageSize = 1 }, new BankDepositListFilter(Day, Day, Branch1, null, BankDepositStatus.Posted));
Assert.Single(result.Items);
Assert.Equal(2, result.Pagination.TotalItems);
Assert.Equal(300m, result.TotalAmount);
}
private static CreateBankDepositRequest Request(decimal amount, int branchId = Branch1, DateOnly? depositDate = null) => new()
{
BranchId = branchId,
BusinessDate = Day,
DepositDate = depositDate,
Amount = amount,
FromGlCashAccountId = FakeGeneralLedger.CashAccountId,
ToGlBankAccountId = FakeGeneralLedger.BankAccountId,
DepositSlipNo = "SLIP-1"
};
}
@@ -0,0 +1,134 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §11).</summary>
public class CashInHandServiceTests
{
private static readonly DateOnly Today = Clock.Today;
private static readonly DateOnly Start = Today.AddDays(-10);
[Fact]
public async Task OnlyCashIsCounted()
{
await using var db = CreateContext();
var day = Today.AddDays(-2);
db.SalesSlips.AddRange(
CashSlip(day, cash: 300, card: 200), // only the 300 is till cash
CashSlip(day, cash: 0, card: 900));
db.SalesInvoices.Add(new SalesInvoice
{
SalesInvoiceId = 1, InvoiceNo = "INV-1", InvoiceDate = At(day.AddDays(-5)), Status = SalesInvoiceStatus.Posted, BranchId = Branch1
});
db.SalesInvoicePayments.AddRange(
new SalesInvoicePayment { SalesInvoicePaymentId = 1, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cash, Amount = 150 },
new SalesInvoicePayment { SalesInvoicePaymentId = 2, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Card, Amount = 500 },
new SalesInvoicePayment { SalesInvoicePaymentId = 3, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cheque, Amount = 70 });
db.Expenses.Add(new Expense { ExpenseId = 1, DocNo = "EXP-1", BranchId = Branch1, ExpenseDate = day, Amount = 100, Status = ExpenseStatus.Posted });
db.ExpensePayments.AddRange(
new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.Cash, Amount = 40 },
new ExpensePayment { ExpensePaymentId = 2, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.BankTransfer, Amount = 60 });
await db.SaveChangesAsync();
var p = (await CashService(db, Start).GetPositionAsync(day, Branch1)).Position;
Assert.Equal(450m, p.CashIn);
Assert.Equal(40m, p.CashOut);
Assert.Equal(410m, p.NetCash);
Assert.Equal(410m, p.Closing);
}
[Fact]
public async Task UndepositedCash_CarriesToTheNextDay_AndDepositsReduceIt()
{
await using var db = CreateContext();
var d1 = Today.AddDays(-3);
var d2 = Today.AddDays(-2);
db.SalesSlips.AddRange(CashSlip(d1, 1000), CashSlip(d2, 500));
db.BankDeposits.AddRange(
Deposit(d1, 600),
Deposit(d1, 999, status: BankDepositStatus.Cancelled)); // ignored
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(d1, d2, Branch1)).Days;
Assert.Equal((0m, 1000m, 600m, 400m), (days[0].Opening, days[0].NetCash, days[0].Deposited, days[0].Closing));
Assert.Equal((400m, 500m, 0m, 900m), (days[1].Opening, days[1].NetCash, days[1].Deposited, days[1].Closing));
}
[Fact]
public async Task Opening_IsCarriedInFromBeforeTheWindow()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(CashSlip(Today.AddDays(-6), 700), CashSlip(Today.AddDays(-5), 300));
db.BankDeposits.Add(Deposit(Today.AddDays(-5), 200));
await db.SaveChangesAsync();
var day = (await CashService(db, Start).GetPositionAsync(Today.AddDays(-1), Branch1)).Position;
Assert.Equal(800m, day.Opening);
Assert.Equal(800m, day.Closing);
}
[Fact]
public async Task CashBeforeTheTrackingStart_IsNotCarried()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(CashSlip(Start.AddDays(-1), 5000), CashSlip(Start, 100));
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(Start.AddDays(-1), Start, Branch1)).Days;
Assert.Equal(5000m, days[0].NetCash); // still reported for the day itself
Assert.Equal(0m, days[1].Opening); // but not carried into tracking
Assert.Equal(100m, days[1].Closing);
}
[Fact]
public async Task Available_IsTheLowestClosingFromThatDayToToday()
{
await using var db = CreateContext();
var d1 = Today.AddDays(-2);
db.SalesSlips.Add(CashSlip(d1, 1000));
db.BankDeposits.Add(Deposit(Today.AddDays(-1), 800)); // already took most of it the next day
await db.SaveChangesAsync();
var position = await CashService(db, Start).GetPositionAsync(d1, Branch1);
Assert.Equal(1000m, position.Position.Closing);
Assert.Equal(200m, position.AvailableToDeposit);
}
[Fact]
public async Task Branches_AreKeptApart()
{
await using var db = CreateContext();
var day = Today.AddDays(-1);
db.SalesSlips.AddRange(CashSlip(day, 100, warehouseId: Warehouse1), CashSlip(day, 900, warehouseId: Warehouse2));
await db.SaveChangesAsync();
var service = CashService(db, Start);
Assert.Equal(100m, (await service.GetPositionAsync(day, Branch1)).Position.Closing);
Assert.Equal(900m, (await service.GetPositionAsync(day, Branch2)).Position.Closing);
Assert.Equal(1000m, (await service.GetPositionAsync(day, null)).Position.Closing);
}
[Fact]
public async Task LateEveningSale_CountsOnTheLocalDay()
{
await using var db = CreateContext();
var day = Today.AddDays(-1);
var slip = CashSlip(day, 250);
slip.SlipDate = Clock.ToUtc(day.AddDays(1)).AddMinutes(-30); // 23:30 local
db.SalesSlips.Add(slip);
await db.SaveChangesAsync();
var days = (await CashService(db, Start).GetDailyAsync(day, day.AddDays(1), Branch1)).Days;
Assert.Equal(250m, days[0].CashIn);
Assert.Equal(0m, days[1].CashIn);
}
}
@@ -0,0 +1,158 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Customers;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Sales Configuration decides which optional customer fields exist (phone, email, address, city, country, credit limit,
/// credit days). The forms hide a switched-off field; the server refuses a new value for it and keeps what is stored.
/// </summary>
public class CustomerFieldSwitchTests
{
private static CustomerService Svc(ErpDbContext db) =>
new(new Repository<Customer>(db), new Repository<Warehouse>(db), new FakeBranchConfig(),
new Repository<SalesConfig>(db), new Repository<SalesInvoice>(db), new Repository<SalesInvoicePayment>(db),
new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{
var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
tweak(config);
db.SalesConfig.Add(config);
await db.SaveChangesAsync();
}
private static CreateCustomerRequest NewCustomer(Action<CreateCustomerRequest>? tweak = null)
{
var request = new CreateCustomerRequest { CustomerCode = "C-1", Name = "Nimal Perera", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private static UpdateCustomerRequest Same(Customer c, Action<UpdateCustomerRequest>? tweak = null)
{
var request = new UpdateCustomerRequest
{
CustomerCode = c.CustomerCode, CustomerType = c.CustomerType, Name = c.Name, DisplayName = c.DisplayName,
Phone = c.Phone, Email = c.Email, AddressLine1 = c.AddressLine1, AddressLine2 = c.AddressLine2,
City = c.City, Country = c.Country, TaxRegistrationNo = c.TaxRegistrationNo,
CreditLimit = c.CreditLimit, CreditDays = c.CreditDays, BranchId = c.BranchId
};
tweak?.Invoke(request);
return request;
}
[Fact]
public async Task EveryFieldIsOn_WhenNothingIsConfigured_SoExistingBehaviourIsUnchanged()
{
await using var db = CreateContext(); // no sales_config row at all
var created = (await Svc(db).CreateAsync(NewCustomer(r =>
{
r.Phone = "0771234567"; r.Email = "a@b.lk"; r.AddressLine1 = "No. 12"; r.City = "Colombo";
r.Country = "Sri Lanka"; r.CreditLimit = 5000m; r.CreditDays = 30;
}))).Value;
Assert.Equal(("0771234567", "a@b.lk", "No. 12", "Colombo", "Sri Lanka"), (created.Phone, created.Email, created.AddressLine1, created.City, created.Country));
Assert.Equal((5000m, 30), (created.CreditLimit, created.CreditDays));
}
[Theory]
[InlineData("phone")]
[InlineData("email")]
[InlineData("address")]
[InlineData("city")]
[InlineData("country")]
[InlineData("creditLimit")]
[InlineData("creditDays")]
public async Task Create_ANewValueForASwitchedOffField_IsRefused(string field)
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = field != "phone";
c.CustomerEmailEnabled = field != "email";
c.CustomerAddressEnabled = field != "address";
c.CustomerCityEnabled = field != "city";
c.CustomerCountryEnabled = field != "country";
c.CustomerCreditLimitEnabled = field != "creditLimit";
c.CustomerCreditDaysEnabled = field != "creditDays";
});
var ex = await Assert.ThrowsAsync<DomainException>(() => Svc(db).CreateAsync(NewCustomer(r =>
{
switch (field)
{
case "phone": r.Phone = "0771234567"; break;
case "email": r.Email = "a@b.lk"; break;
case "address": r.AddressLine2 = "Wellawatte"; break;
case "city": r.City = "Colombo"; break;
case "country": r.Country = "Sri Lanka"; break;
case "creditLimit": r.CreditLimit = 100m; break;
default: r.CreditDays = 7; break;
}
})));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Empty(db.Customers.Where(c => c.CustomerCode == "C-1"));
}
[Fact]
public async Task Create_WithTheSwitchedOffFieldsLeftBlank_Works()
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = c.CustomerEmailEnabled = c.CustomerAddressEnabled = false;
c.CustomerCityEnabled = c.CustomerCountryEnabled = false;
c.CustomerCreditLimitEnabled = c.CustomerCreditDaysEnabled = false;
});
var created = (await Svc(db).CreateAsync(NewCustomer())).Value;
Assert.Equal("Nimal Perera", created.Name);
Assert.Null(created.Phone);
Assert.Equal((0m, 0), (created.CreditLimit, created.CreditDays));
}
[Fact]
public async Task Update_KeepsAStoredValueWhoseFieldWasSwitchedOff_EvenWhenTheFormSendsItBlank()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => { r.Phone = "0771234567"; r.CreditLimit = 5000m; r.CreditDays = 30; }))).Value;
await SwitchesAsync(db, c => { c.CustomerPhoneEnabled = false; c.CustomerCreditLimitEnabled = false; c.CustomerCreditDaysEnabled = false; });
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
// The edit form no longer has the fields, so it sends blanks / zeros.
var updated = (await svc.UpdateAsync(created.CustomerId,
Same(stored, r => { r.Name = "Nimal P."; r.Phone = null; r.CreditLimit = 0m; r.CreditDays = 0; }), stored.RowVersion)).Value;
Assert.Equal("Nimal P.", updated.Name);
Assert.Equal("0771234567", updated.Phone);
Assert.Equal((5000m, 30), (updated.CreditLimit, updated.CreditDays));
}
[Fact]
public async Task Update_ADifferentValueForASwitchedOffField_IsRefused()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => r.CreditLimit = 5000m))).Value;
await SwitchesAsync(db, c => c.CustomerCreditLimitEnabled = false);
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
}
@@ -0,0 +1,257 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests;
/// <summary>Aggregates of the Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §7).</summary>
public class DayEndReportServiceTests
{
private static readonly InMemoryDatabaseRoot Root = new();
// Asia/Colombo is UTC+05:30 all year (no DST), so local midnight is 18:30 UTC the day before.
private static readonly DateOnly Day = new(2026, 10, 8);
private static DateTime UtcAt(int hour, int minute = 0) => new(2026, 10, 8, hour, minute, 0, DateTimeKind.Utc);
[Fact]
public async Task Day_MixOfSlipsBundlesAndInvoices_TotalsAndSplitsByMethod()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5), total: 1000, paid: 600, cash: 400, card: 200), // partial, mixed
Slip(2, UtcAt(6), total: 200, paid: 200, cash: 200, card: 0), // all cash
Slip(3, UtcAt(7), total: 999, paid: 999, cash: 999, card: 0, status: SalesSlipStatus.Draft));
db.BundleSales.Add(new BundleSale { BundleSaleId = 1, BundleDate = UtcAt(8), Status = BundleSaleStatus.Posted, GrandTotal = 500, CashAmount = 0, CardAmount = 500 });
db.SalesInvoices.Add(Invoice(1, UtcAt(9), netPayable: 2000, paid: 1500));
db.SalesInvoicePayments.AddRange(
Payment(1, invoiceId: 1, UtcAt(9), SalesPaymentMethod.Cash, 500),
Payment(2, invoiceId: 1, UtcAt(10), SalesPaymentMethod.Card, 1000));
SeedExpenseSetup(db);
db.Expenses.AddRange(
Expense(1, Day, 300, ExpenseStatus.Posted),
Expense(2, Day, 700, ExpenseStatus.Cancelled),
Expense(3, Day.AddDays(1), 50, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(3700m, s.TotalSales);
Assert.Equal(2800m, s.PaidAmount);
Assert.Equal(900m, s.DueAmount);
Assert.Equal(1100m, s.PaidByCash);
Assert.Equal(1700m, s.PaidByCard);
Assert.Equal(0m, s.PaidByOther);
Assert.Equal(4, s.SalesCount);
Assert.Equal(1, s.CashSalesCount);
Assert.Equal(1, s.CardSalesCount);
Assert.Equal(2, s.MixedSalesCount);
Assert.Equal(0, s.UnpaidSalesCount);
Assert.Equal(300m, s.ExpenseTotal);
Assert.Equal(3400m, s.Income);
Assert.Single(report.Expenses);
Assert.Equal("Electricity", report.Expenses[0].TitleName);
}
[Fact]
public async Task Day_SaleLateInTheLocalEvening_CountsOnTheLocalDay()
{
await using var db = CreateContext();
// 19:00 UTC on Oct 7 is 00:30 on Oct 8 in Colombo; 18:00 UTC on Oct 8 is 23:30 on Oct 8.
db.SalesSlips.AddRange(
Slip(1, new DateTime(2026, 10, 7, 19, 0, 0, DateTimeKind.Utc), total: 100, paid: 100, cash: 100, card: 0),
Slip(2, new DateTime(2026, 10, 8, 18, 0, 0, DateTimeKind.Utc), total: 40, paid: 40, cash: 40, card: 0),
Slip(3, new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc), total: 7, paid: 7, cash: 7, card: 0));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day.AddDays(1), null);
Assert.Equal([0m, 140m, 7m], report.Days.Select(d => d.TotalSales));
Assert.Equal(147m, report.Totals.TotalSales);
}
[Fact]
public async Task Day_PaymentOnAnEarlierInvoice_IsACollectionAndCountsInNetCash()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5).AddDays(-3), netPayable: 800, paid: 250));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(6), SalesPaymentMethod.Cash, 250));
db.SalesSlips.Add(Slip(1, UtcAt(7), total: 60, paid: 60, cash: 60, card: 0));
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 30, ExpenseStatus.Posted));
db.ExpensePayments.Add(new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, Amount = 30, PaymentDate = UtcAt(8), PaymentMethod = ExpensePaymentMethod.Cash });
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(60m, s.TotalSales);
Assert.Equal(60m, s.PaidAmount);
Assert.Equal(250m, s.EarlierInvoiceCollections);
Assert.Equal(60m + 250m - 30m, s.NetCash);
}
[Fact]
public async Task Day_ShowsBankDepositsAndTheCashLeftInHand()
{
await using var db = CreateContext();
db.SalesSlips.AddRange(
Slip(1, UtcAt(5).AddDays(-1), total: 300, paid: 300, cash: 300, card: 0), // yesterday, never banked
Slip(2, UtcAt(6), total: 1000, paid: 1000, cash: 1000, card: 0));
db.BankDeposits.AddRange(
BankDeposit(1, Day, 800, BankDepositStatus.Posted),
BankDeposit(2, Day, 5000, BankDepositStatus.Cancelled));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal((300m, 1000m, 800m, 500m), (s.OpeningCashInHand, s.NetCash, s.CashDeposited, s.ClosingCashInHand));
var deposit = Assert.Single(report.Deposits);
Assert.Equal(("BD-1", "BOC Current", 800m), (deposit.DocNo, deposit.BankAccountName, deposit.Amount));
var range = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day, null);
Assert.Equal((0m, 800m, 500m), (range.Totals.OpeningCashInHand, range.Totals.CashDeposited, range.Totals.ClosingCashInHand));
}
[Fact]
public async Task Day_Profit_IsSalesLessStockCostLessExpenses_AndCanBeALoss()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 1000, paid: 1000));
db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(5), SalesPaymentMethod.Card, 1000));
db.SalesSlips.Add(Slip(1, UtcAt(6), total: 200, paid: 0, cash: 0, card: 0));
db.StockLedger.AddRange(
Ledger(1, DocumentTypes.SalesInvoice, 1, Direction.Out, 600),
Ledger(2, DocumentTypes.SalesInvoice, 1, Direction.Out, 100),
Ledger(3, DocumentTypes.SalesSlip, 1, Direction.Out, 150),
Ledger(4, DocumentTypes.SalesInvoice, 1, Direction.In, 999)); // not a sale's cost
SeedExpenseSetup(db);
db.Expenses.Add(Expense(1, Day, 900, ExpenseStatus.Posted));
await db.SaveChangesAsync();
var report = await CreateService(db).GetDayAsync(Day, null);
var s = report.Summary;
Assert.Equal(850m, s.CostOfGoodsSold);
Assert.Equal(350m, s.GrossProfit);
Assert.Equal(-550m, s.NetProfit);
Assert.Equal(2, report.Sales.Count);
var invoice = report.Sales.Single(x => x.DocType == "Invoice");
Assert.Equal(("Card", 700m, 300m), (invoice.PaidBy, invoice.Cost, invoice.GrossProfit));
Assert.Equal("Unpaid", report.Sales.Single(x => x.DocType == "Slip").PaidBy);
}
[Fact]
public async Task Day_UnpaidInvoice_CountsAsUnpaidAndDue()
{
await using var db = CreateContext();
db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 450, paid: 0));
await db.SaveChangesAsync();
var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
Assert.Equal(1, s.UnpaidSalesCount);
Assert.Equal(450m, s.DueAmount);
}
[Fact]
public async Task Daily_FillsQuietDaysWithZeros()
{
await using var db = CreateContext();
var report = await CreateService(db).GetDailyAsync(Day, Day.AddDays(4), null);
Assert.Equal(5, report.Days.Count);
Assert.All(report.Days, d => Assert.Equal(0m, d.Income));
Assert.Equal(Day.AddDays(4), report.Days[^1].Date);
}
[Theory]
[InlineData(1, 0)] // to before from
[InlineData(0, 92)] // 93 days
public async Task Daily_InvalidRange_IsRejected(int fromOffset, int toOffset)
{
await using var db = CreateContext();
var ex = await Assert.ThrowsAsync<DomainException>(() =>
CreateService(db).GetDailyAsync(Day.AddDays(fromOffset), Day.AddDays(toOffset), null));
Assert.Equal(ErrorCodes.DayEndReportRangeInvalid, ex.Code);
}
// --- helpers ------------------------------------------------------------------------------
private static SalesSlip Slip(int id, DateTime at, decimal total, decimal paid, decimal cash, decimal card,
SalesSlipStatus status = SalesSlipStatus.Posted) => new()
{
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = at, Status = status,
GrandTotal = total, PaidAmount = paid, BalanceAmount = total - paid, CashAmount = cash, CardAmount = card
};
private static SalesInvoice Invoice(int id, DateTime at, decimal netPayable, decimal paid) => new()
{
SalesInvoiceId = id, InvoiceNo = $"INV-{id}", InvoiceDate = at, Status = SalesInvoiceStatus.Posted,
GrandTotal = netPayable, NetPayable = netPayable, PaidAmount = paid, BalanceAmount = netPayable - paid
};
private static SalesInvoicePayment Payment(int id, int invoiceId, DateTime at, SalesPaymentMethod method, decimal amount) => new()
{
SalesInvoicePaymentId = id, SalesInvoiceId = invoiceId, PaymentDate = at, PaymentMethod = method, Amount = amount
};
private static StockLedger Ledger(int id, string docType, int docId, Direction direction, decimal value) => new()
{
LedgerId = id, SourceDocType = docType, SourceDocId = docId, Direction = direction, Value = value, QtyBase = 1, ItemId = 1, WarehouseId = 1
};
private static Expense Expense(int id, DateOnly date, decimal amount, ExpenseStatus status) => new()
{
ExpenseId = id, DocNo = $"EXP-{id}", ExpenseDate = date, Amount = amount, Status = status,
ExpenseCategoryId = 1, ExpenseTitleId = 1
};
private static BankDeposit BankDeposit(int id, DateOnly businessDate, decimal amount, BankDepositStatus status) => new()
{
BankDepositId = id, DocNo = $"BD-{id}", BranchId = 1, BusinessDate = businessDate, DepositDate = businessDate, Amount = amount,
Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current"
};
private static void SeedExpenseSetup(ErpDbContext db)
{
db.ExpenseCategories.Add(new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility" });
db.ExpenseTitles.Add(new ExpenseTitle { ExpenseTitleId = 1, ExpenseCategoryId = 1, Name = "Electricity", GlExpenseAccountCode = "5200-01" });
}
private static DayEndReportService CreateService(ErpDbContext db)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DayEndReport:TimeZone"] = "Asia/Colombo" })
.Build();
var clock = new BusinessDayClock(config);
var branch = new CurrentBranchContext(new TestCurrentUser());
var cash = new CashInHandService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
return new DayEndReportService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
}
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
}
+489
View File
@@ -0,0 +1,489 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Products;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests;
/// <summary>Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13).</summary>
public class DualApprovalTests
{
private static readonly InMemoryDatabaseRoot Root = new();
// Requester, two approvers, and a clerk who can approve but isn't a manager.
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
private const int ApproverRoleId = 2, ClerkRoleId = 3;
// ReasonId is an ordinary adjustment reason (VAR); WastageReasonId is a wastage one (DMG), which has its own approval switch.
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1, WastageReasonId = 2;
// --- Stock adjustments ------------------------------------------------------------------
[Fact]
public async Task StockAdjustment_IsARequest_NothingMovesUntilApproved()
{
var h = await Harness.CreateAsync();
var dto = await h.Stock.CreateAsync(Request(-3));
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(2, dto.RequiredApprovals);
Assert.Equal(1, dto.PendingLevel);
Assert.Empty(h.Mutator.Calls);
Assert.Empty(h.Gl.Posted);
var pending = Assert.Single(h.Notifications.Targeted, n => n.ModuleKey == "approval.pending");
Assert.Equal([ApproverA, ApproverB, Clerk], pending.UserIds.Order()); // never the requester
}
[Fact]
public async Task StockAdjustment_TwoDifferentApprovers_ThenItPosts()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
h.User.Be(Requester);
var own = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalForbidden, own.Code);
h.User.Be(ApproverA);
var first = await h.Stock.ApproveAsync(id, "counted twice");
Assert.Equal(AdjustmentStatus.PendingSecondApproval, first.Status);
Assert.Empty(h.Mutator.Calls);
var twice = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalSamePerson, twice.Code);
h.User.Be(ApproverB);
var posted = await h.Stock.ApproveAsync(id, null);
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
Assert.Equal((ApproverA, ApproverB), (posted.ApprovedLevel1By, posted.ApprovedLevel2By));
var call = Assert.Single(h.Mutator.Calls);
Assert.Equal(-3m, Assert.Single(call).QtyDelta);
Assert.Single(h.Gl.Posted);
Assert.NotNull(posted.PostedAt);
Assert.Equal([1, 2], posted.ApprovalHistory!.Select(s => s.Level));
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.approved" && n.UserIds.SequenceEqual([Requester]));
}
[Fact]
public async Task StockAdjustment_Rejected_NeedsAReason_AndNeverPosts()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(5))).AdjustmentId;
h.User.Be(ApproverA);
var noReason = await Assert.ThrowsAsync<DomainException>(() => h.Stock.RejectAsync(id, " "));
Assert.Equal(ErrorCodes.ApprovalReasonRequired, noReason.Code);
var rejected = await h.Stock.RejectAsync(id, "wrong warehouse");
Assert.Equal((AdjustmentStatus.Rejected, "wrong warehouse"), (rejected.Status, rejected.RejectReason));
h.User.Be(ApproverB);
var late = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalNotPending, late.Code);
Assert.Empty(h.Mutator.Calls);
Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.rejected");
}
[Fact]
public async Task StockAdjustment_ApprovalsOff_PostsOnCreate()
{
var h = await Harness.CreateAsync(stockLevels: 0);
var dto = await h.Stock.CreateAsync(Request(2));
Assert.Equal((AdjustmentStatus.Posted, 0), (dto.Status, dto.RequiredApprovals));
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task StockAdjustment_BelowTheValueThreshold_NeedsOneApproval()
{
// 3 units at 10.00 each = 30.00, below the 1,000 threshold.
var h = await Harness.CreateAsync(stockThreshold: 1000);
var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
h.User.Be(ApproverA);
var posted = await h.Stock.ApproveAsync(id, null);
Assert.Equal((AdjustmentStatus.Posted, 1), (posted.Status, posted.RequiredApprovals));
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task StockAdjustment_OnlyTheRequesterCanWithdraw()
{
var h = await Harness.CreateAsync();
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
var other = await Assert.ThrowsAsync<DomainException>(() => h.Stock.CancelAsync(id));
Assert.Equal(ErrorCodes.ApprovalForbidden, other.Code);
h.User.Be(Requester);
Assert.Equal(AdjustmentStatus.Cancelled, (await h.Stock.CancelAsync(id)).Status);
}
[Fact]
public async Task SecondApproval_CanBeLimitedToRoles()
{
var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(id, null);
h.User.Be(Clerk);
var clerk = await Assert.ThrowsAsync<DomainException>(() => h.Stock.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.ApprovalRoleRequired, clerk.Code);
h.User.Be(ApproverB);
Assert.Equal(AdjustmentStatus.Posted, (await h.Stock.ApproveAsync(id, null)).Status);
}
// --- Price adjustments ------------------------------------------------------------------
[Fact]
public async Task PriceAdjustment_AppliesOnlyAfterTheSecondApproval_AndKeepsHistory()
{
var h = await Harness.CreateAsync();
var dto = await h.Prices.CreateAsync(PriceRequest(flat: 120m));
Assert.Equal(PriceAdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
h.User.Be(ApproverA);
await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
h.User.Be(ApproverB);
var applied = await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
Assert.Equal(PriceAdjustmentStatus.Approved, applied.Status);
Assert.Equal(120m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
var history = Assert.Single(await h.Prices.ItemHistoryAsync(ItemId));
Assert.Equal((100m, 120m, ApproverA, ApproverB, dto.DocNo), (history.OldFlatPrice, history.NewFlatPrice, history.ApprovedLevel1By, history.ApprovedLevel2By, history.DocNo));
}
[Fact]
public async Task PriceAdjustment_ItemRepricedSinceTheRequest_IsRefusedAsStale()
{
var h = await Harness.CreateAsync();
var id = (await h.Prices.CreateAsync(PriceRequest(flat: 120m))).PriceAdjustmentId;
h.User.Be(ApproverA);
await h.Prices.ApproveAsync(id, null);
var item = await h.Db.Items.FirstAsync();
item.SellingFlatPrice = 105m; // someone changed it in between
await h.Db.SaveChangesAsync();
h.User.Be(ApproverB);
var stale = await Assert.ThrowsAsync<DomainException>(() => h.Prices.ApproveAsync(id, null));
Assert.Equal(ErrorCodes.PriceAdjustmentStale, stale.Code);
Assert.Empty(await h.Prices.ItemHistoryAsync(ItemId));
}
[Fact]
public async Task PriceAdjustment_OnePendingRequestPerItem()
{
var h = await Harness.CreateAsync();
await h.Prices.CreateAsync(PriceRequest(flat: 120m));
var second = await Assert.ThrowsAsync<DomainException>(() => h.Prices.CreateAsync(PriceRequest(flat: 130m)));
Assert.Equal(ErrorCodes.PriceAdjustmentItemPending, second.Code);
}
[Theory]
[InlineData(100, 120, 20)]
[InlineData(100, 80, 20)]
[InlineData(-1, 50, 100)] // setting a price where there was none (-1 = no price)
[InlineData(50, -1, 100)] // clearing one
[InlineData(40, 40, 0)]
public void ChangePercent_MeasuresTheSizeOfAPriceChange(int oldPrice, int newPrice, int expected)
{
decimal? Price(int v) => v < 0 ? null : v;
Assert.Equal(expected, PriceAdjustmentService.ChangePercent(Price(oldPrice), Price(newPrice)));
}
[Theory]
[InlineData(2, 0, 5000, 2)] // no threshold: always two
[InlineData(2, 1000, 50, 1)] // below threshold: one
[InlineData(2, 1000, 1000, 2)] // at threshold: two
[InlineData(1, 1000, 5000, 1)]
[InlineData(0, 0, 5000, 0)]
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
// --- Wastage: its own approval switch ------------------------------------------------------
[Fact]
public async Task Wastage_WithTheSwitchOff_ReducesStockAtOnce_EvenWhenAdjustmentsNeedTwoApprovals()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.Posted, dto.Status);
Assert.Equal(0, dto.RequiredApprovals);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task Wastage_WithTheSwitchOn_IsARequest_AndMovesNothingUntilApproved()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 2);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(2, dto.RequiredApprovals);
Assert.Empty(h.Mutator.Calls);
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Empty(h.Mutator.Calls); // one of two: still nothing
h.User.Be(ApproverB);
var posted = await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task TheWastageSwitch_DoesNotLoosenOrdinaryAdjustments_AndTheirLevelsDoNotTouchWastage()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
// An ordinary adjustment (not a wastage reason) still needs two approvals.
var ordinary = await h.Stock.CreateAsync(Request(-3));
Assert.Equal(AdjustmentStatus.PendingApproval, ordinary.Status);
// A stock GAIN with a wastage reason is not wastage, so it follows the adjustment levels too.
var gain = await h.Stock.CreateAsync(Request(+3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, gain.Status);
}
[Fact]
public async Task WastageList_ReturnsOnlyAdjustmentsWithAWastageReason()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
await h.Stock.CreateAsync(Request(-2));
var all = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null);
var wastage = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true);
Assert.Equal(2, all.Items.Count);
var only = Assert.Single(wastage.Items);
Assert.Equal(WastageReasonId, only.ReasonCodeId);
}
[Fact]
public async Task WastageList_CanBeNarrowedToOneItem()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
var forItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: ItemId);
var otherItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: 999);
Assert.Single(forItem.Items);
Assert.Empty(otherItem.Items);
}
[Fact]
public void WastageApproval_IsOffByDefault_AndTheSettingIsReadBack()
{
Assert.Equal(0, new ApprovalConfig().WastageLevels);
Assert.True(ERPCore.Domain.WastageReasonCodes.IsWastage("dmg"));
Assert.False(ERPCore.Domain.WastageReasonCodes.IsWastage("VAR"));
}
// --- helpers ------------------------------------------------------------------------------
private static CreateAdjustmentRequest Request(decimal qty, int reasonId = ReasonId) => new()
{
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = reasonId,
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
};
private static CreatePriceAdjustmentRequest PriceRequest(decimal flat) => new()
{
Reason = "Supplier price rise",
Lines = [new PriceAdjustmentLineInput { ItemId = ItemId, NewMinPrice = 90m, NewMaxPrice = 150m, NewFlatPrice = flat }]
};
private sealed class Harness
{
public required ErpDbContext Db { get; init; }
public required SwitchableUser User { get; init; }
public required RecordingMutator Mutator { get; init; }
public required FakeGeneralLedger Gl { get; init; }
public required RecordingNotifications Notifications { get; init; }
public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; }
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null, int wastageLevels = 0)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
new TestCurrentUser(), new CurrentBranchContext(new TestCurrentUser()));
db.Roles.AddRange(
new Role { RoleId = 1, Code = "Requester", Name = "Requester" },
new Role { RoleId = ApproverRoleId, Code = "Approver", Name = "Approver" },
new Role { RoleId = ClerkRoleId, Code = "Clerk", Name = "Clerk" });
db.Users.AddRange(
new User { UserId = ERPCore.Domain.Entities.User.SystemUserId, Username = "system", DisplayName = "System" },
new User { UserId = Requester, Username = "req", DisplayName = "Requester", RoleId = 1 },
new User { UserId = ApproverA, Username = "a", DisplayName = "Approver A", RoleId = ApproverRoleId },
new User { UserId = ApproverB, Username = "b", DisplayName = "Approver B", RoleId = ApproverRoleId },
new User { UserId = Clerk, Username = "c", DisplayName = "Clerk", RoleId = ClerkRoleId });
db.Permissions.AddRange(
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments" },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments" });
foreach (var roleId in new[] { ApproverRoleId, ClerkRoleId })
foreach (var permissionId in new[] { 45, 58 })
db.RolePermissions.Add(new RolePermission { RoleId = roleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 45, Actions = RolePermissionAction.View | RolePermissionAction.Create });
db.Branches.Add(new Branch { BranchId = 1, Code = "BR-001", Name = "Main" });
db.Warehouses.Add(new Warehouse { WarehouseId = WarehouseId, Code = "WH-1", Name = "Main Store", BranchId = 1 });
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
db.ReasonCodes.AddRange(
new ReasonCode { ReasonCodeId = ReasonId, Code = "VAR", Description = "Variance", Context = ReasonContext.Adjustment },
new ReasonCode { ReasonCodeId = WastageReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ApprovalConfig.Add(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds,
WastageLevels = wastageLevels
});
await db.SaveChangesAsync();
var user = new SwitchableUser(Requester, "Requester");
var uow = new PassThroughUnitOfWork(db);
var notifications = new RecordingNotifications();
var approvals = new ApprovalService(
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
var gl = new FakeGeneralLedger();
var mutator = new RecordingMutator();
var numbers = new FakeNumberSequence();
var stock = new AdjustmentService(
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
new StubGlAccountConfigService(
(GlAccountRole.Inventory, "1200"),
(GlAccountRole.InventoryGain, "4900"),
(GlAccountRole.InventoryLoss, "5900")),
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<PriceAdjustmentLine>(db), new Repository<Item>(db),
new Repository<ItemPriceHistory>(db), new Repository<User>(db), new Repository<ApprovalConfig>(db),
approvals, notifications, numbers, user, uow);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
}
}
/// <summary>A signed-in user whose identity the test switches between steps.</summary>
private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser
{
private static readonly Dictionary<int, string> Roles = new()
{
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
};
private static readonly Dictionary<int, int> RoleIds = new()
{
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
};
public int AuditUserId { get; private set; } = userId;
public int? RoleId { get; private set; } = RoleIds[userId];
public string? RoleCode { get; private set; } = roleCode;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
public bool IsAdmin => false;
public int? BranchId => null;
public void Be(int userId)
{
AuditUserId = userId;
RoleId = RoleIds[userId];
RoleCode = Roles[userId];
}
}
/// <summary>Records what would be posted to stock; every unit is worth 10.00.</summary>
private sealed class RecordingMutator : IStockMutator
{
public List<IReadOnlyList<StockDelta>> Calls { get; } = new();
public Task<IReadOnlyList<StockLedger>> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
{
Calls.Add(deltas);
IReadOnlyList<StockLedger> refs = deltas.Select(d => new StockLedger
{
ItemId = d.ItemId, WarehouseId = warehouseId, QtyBase = Math.Abs(d.QtyDelta), UnitCost = 10m, Value = Math.Abs(d.QtyDelta) * 10m,
Direction = d.QtyDelta < 0 ? Direction.Out : Direction.In, SourceDocType = sourceDocType, SourceDocId = sourceDocId, CreatedAt = now
}).ToList();
return Task.FromResult(refs);
}
}
/// <summary>10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold.</summary>
private sealed class FixedValuation : IFifoCostingService
{
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO"));
public Task<StockLayer> CreateInboundLayerAsync(int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null) => throw new NotSupportedException();
public Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
=> throw new NotSupportedException();
public 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, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
}
private sealed class RecordingNotifications : INotificationService
{
public List<(IReadOnlyList<int> UserIds, string ModuleKey, string Message)> Targeted { get; } = new();
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null,
CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default)
{
Targeted.Add((userIds.ToList(), moduleKey, message));
return Task.CompletedTask;
}
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.20" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ERPCore\ERPCore.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -0,0 +1,151 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Time;
using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
/// <summary>
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
/// </summary>
public class GlAccountCodeValidatorTests
{
[Fact]
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{
var validator = CreateValidator(
new(),
new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100"),
StubGeneralLedgerService.Postable("2000")
]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{
var validator = CreateValidator(
new(),
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
(GlAccountRole.Inventory, "1100"),
(GlAccountRole.Cash, "1000-01"),
(GlAccountRole.InventoryLoss, "5100"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => validator.StartAsync(CancellationToken.None));
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message);
}
/// <summary>
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
/// "everything is missing" would refuse to start on a contract change.
/// </summary>
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([]),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
/// <summary>
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
/// mapping — escaped a hosted service and aborted the host.
/// </summary>
[Theory]
[MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService(failure),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
public static TheoryData<Exception> UnexpectedFailures() =>
[
StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"),
new OperationCanceledException(),
];
[Fact]
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var validator = CreateValidator(new(), gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
[Fact]
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
{
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
gl,
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
await validator.StartAsync(CancellationToken.None);
Assert.Equal(1, gl.Calls);
}
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
{
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 0, 0, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 29, 59, DateTimeKind.Utc)));
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
private static GlAccountCodeValidator CreateValidator(
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection()
.AddScoped<IGeneralLedgerService>(_ => gl)
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
.BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
}
}
@@ -0,0 +1,234 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The point of this service is that a mapping GL cannot post to never reaches the database.
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
/// existed in GL at all.
/// </summary>
public class GlAccountConfigServiceTests
{
[Fact]
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.Postable("2000", "GRNI")
]);
var service = CreateService(db, gl);
var before = (await service.GetAsync()).RowVersion;
var result = await service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
Assert.True(result.RowVersion > before);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
}
[Fact]
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Contains("9999", ex.Message);
Assert.Contains("Clearing", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>
/// GL rejects a journal line against a non-postable account, so storing one only defers the
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
/// </summary>
[Fact]
public async Task UpdateAsync_RejectsANonPostableAccount()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Contains("not postable", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
[Fact]
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
await AssertNothingStoredAsync(db);
}
[Fact]
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(503, ex.StatusCode);
}
[Fact]
public async Task UpdateAsync_RejectsAStaleIfMatch()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
[Fact]
public async Task UpdateAsync_TreatsBlankAsCleared()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var service = CreateService(db, gl);
var rowVersion = (await service.GetAsync()).RowVersion;
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
}
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
[Fact]
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var codes = await service.GetCodesAsync();
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
Assert.Contains("Cogs", ex.Message);
}
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
[Fact]
public async Task GetCodesAsync_ReadsOncePerScope()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var first = await service.GetCodesAsync();
var second = await service.GetCodesAsync();
Assert.Same(first, second);
}
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
[Fact]
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var result = await service.GetAsync();
Assert.True(result.Value.GlUnavailable);
Assert.Empty(result.Value.Options);
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
Assert.Equal("1100", inventory.AccountCode);
}
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
[Fact]
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
{
await using var db = await CreateSeededContextAsync(c =>
{
c.InventoryAccountCode = "1100";
c.CashAccountCode = "1000-01";
});
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
]));
var result = await service.GetAsync();
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
}
/// <summary>The picker must only offer what validation will accept.</summary>
[Fact]
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
new GlAccount("1200", "Retired", 1, true, false)
]));
var options = (await service.GetAsync()).Value.Options;
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
}
private static async Task AssertNothingStoredAsync(ErpDbContext db)
{
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
Assert.Null(row!.InventoryAccountCode);
Assert.Null(row.ClearingAccountCode);
Assert.Null(row.CashAccountCode);
}
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
{
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
configure?.Invoke(config);
db.GlAccountConfig.Add(config);
await db.SaveChangesAsync();
return db;
}
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
}
+366
View File
@@ -0,0 +1,366 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
/// </summary>
public class ItemTrackingTests
{
private static readonly InMemoryDatabaseRoot Root = new();
private const int Wh1 = 1;
private const int Wh2 = 2;
private const int SerialItemId = 10;
private const int BatchItemId = 20;
private const int PlainItemId = 30;
// --- sales line validation --------------------------------------------------------------
[Fact]
public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
}
[Fact]
public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
{
await using var db = await SeedAsync();
// D6: stock received before serial capture — a layer with no serial.
db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
await db.SaveChangesAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
// Serial 3 is in stock at warehouse 2.
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SoldSerial_IsNotAvailable()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
}
[Fact]
public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var documentSerials = new HashSet<int>();
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task SalesLine_ValidSerials_AreReturned()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet<int>());
Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
}
[Fact]
public async Task SalesLine_UntrackedItem_RejectsSerials()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task SalesLine_ExpiredBatch_IsBlocked()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
}
[Fact]
public async Task SalesLine_BatchOfAnotherItem_IsRejected()
{
await using var db = await SeedAsync();
var sales = CreateSalesDomain(db);
var ex = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- stock documents (transfers / adjustments / purchase returns) ------------------------
[Fact]
public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock }));
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
// ...a write-off or purchase return (InStock or Damaged) takes it.
var serials = await tracking.ResolveOutboundAsync(
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
Assert.Single(serials);
}
[Fact]
public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
}
[Fact]
public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
// SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet<string>());
await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
await db.SaveChangesAsync();
var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
}
[Fact]
public async Task Move_RecordsHistory_WithPreviousWarehouse()
{
await using var db = await SeedAsync();
var tracking = CreateSerialTracking(db);
var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
await db.SaveChangesAsync();
var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
Assert.Equal(SerialStatus.InTransit, movement.Status);
Assert.Equal(Wh1, movement.FromWarehouseId);
Assert.Null(movement.WarehouseId);
Assert.Null(serial.WarehouseId);
}
// --- auto-generated serial numbers -------------------------------------------------------
[Fact]
public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
// A draft GRN line already holds PH-0042 (not yet a Serial).
db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
}
[Fact]
public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
{
await using var db = await SeedAsync();
db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
await db.SaveChangesAsync();
var tracking = new ItemTrackingService(db, null!);
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
}
[Fact]
public async Task NextNumbers_RejectsTooMany()
{
await using var db = await SeedAsync();
var tracking = new ItemTrackingService(db, null!);
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
// --- fixtures ---------------------------------------------------------------------------
private static async Task<ErpDbContext> SeedAsync()
{
var db = CreateContext();
db.Items.AddRange(
new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
db.Batches.AddRange(
new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
db.Serials.AddRange(
new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
await db.SaveChangesAsync();
return db;
}
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
// Pricing is never touched by the tracking check, so it is not wired.
private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
new Repository<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
CreateSerialTracking(db));
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), new TestCurrentUser());
private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
{
db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
await db.SaveChangesAsync();
}
// --- Product Configuration "Serial numbers" switch ---------------------------------------
[Fact]
public async Task SwitchOff_SerialItemSells_WithoutSerials()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
// No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet<int>());
Assert.Empty(serials);
}
[Fact]
public async Task SwitchOff_SerialsSent_AreRefused()
{
await using var db = await SeedAsync();
await SwitchSerialNumbersAsync(db, enabled: false);
var sales = CreateSalesDomain(db);
var tracking = CreateSerialTracking(db);
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
var sale = await Assert.ThrowsAsync<DomainException>(() =>
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
}
[Fact]
public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
{
await using var db = await SeedAsync();
Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
}
}
@@ -0,0 +1,117 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>Low-stock / reorder alerts raised after stock issues.</summary>
public class LowStockAlertServiceTests
{
private const int ItemId = 1, WarehouseId = 1;
[Theory]
[InlineData(12, 4, 8, true)] // 12 → 8 crosses the alert level of 10
[InlineData(12, 2, 10, true)] // landing exactly on the level counts
[InlineData(20, 5, 15, false)] // still above the level
[InlineData(9, 3, 6, false)] // was already low — no repeat alert on every sale
public async Task Alerts_OnlyWhenAnIssueCrossesTheReorderPoint(decimal before, decimal issued, decimal after, bool alerts)
{
Assert.Equal(before - issued, after);
await using var db = Seed();
var tracker = new LowStockTracker();
tracker.Track(ItemId, WarehouseId, issued);
var notifications = new RecordingNotifications();
await Service(db, tracker, after, notifications).CheckAsync();
if (!alerts)
{
Assert.Empty(notifications.Raised);
return;
}
var raised = Assert.Single(notifications.Raised);
Assert.Equal(LowStockAlertService.ModuleKey, raised.ModuleKey);
Assert.Contains("SKU-1", raised.Message);
Assert.Contains($"down to {after:0.####} pcs at WH-1", raised.Message);
Assert.Contains("Suggested reorder: 50 pcs", raised.Message);
}
[Fact]
public async Task ItemsWithoutARule_OrUntouched_AreIgnored()
{
await using var db = Seed();
var tracker = new LowStockTracker();
tracker.Track(itemId: 99, WarehouseId, 100); // no rule for this item
var notifications = new RecordingNotifications();
await Service(db, tracker, available: 0, notifications).CheckAsync();
await Service(db, new LowStockTracker(), available: 0, notifications).CheckAsync(); // nothing issued
Assert.Empty(notifications.Raised);
}
[Fact]
public async Task Tracker_SumsIssuesWithinARequest_AndEmptiesWhenDrained()
{
var tracker = new LowStockTracker();
tracker.Track(ItemId, WarehouseId, 3);
tracker.Track(ItemId, WarehouseId, 4);
tracker.Track(ItemId, WarehouseId, 0);
Assert.Equal(7m, tracker.Drain()[(ItemId, WarehouseId)]);
Assert.Empty(tracker.Drain());
}
private static LowStockAlertService Service(ErpDbContext db, LowStockTracker tracker, decimal available, INotificationService notifications)
=> new(tracker, new Repository<ItemReorder>(db), new FixedStock(available), notifications);
private static ErpDbContext Seed()
{
var db = BankDepositTestSupport.CreateContext(); // users, branches, warehouses WH-1 / WH-2
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola 500ml", BaseUomId = 1, CategoryId = 1 });
db.ItemReorders.Add(new ItemReorder { ReorderId = 1, ItemId = ItemId, WarehouseId = WarehouseId, ReorderPoint = 10, ReorderQty = 50 });
db.SaveChanges();
return db;
}
private sealed class FixedStock(decimal available) : IStockService
{
public Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
public Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
int? categoryId = null, int? brandId = null) => throw new NotSupportedException();
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class RecordingNotifications : INotificationService
{
public List<(string ModuleKey, string Title, string Message)> Raised { get; } = new();
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null,
int? relatedEntityId = null, CancellationToken ct = default)
{
Raised.Add((moduleKey, title, message));
return Task.CompletedTask;
}
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message,
string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Notifications;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Tests;
public class NotificationConfigServiceTests
{
[Fact]
public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = false,
InAppEnabled = true
}
});
var service = CreateService(db);
var result = await service.GetAsync();
Assert.Equal(1u, result.RowVersion);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
}
[Fact]
public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db);
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = index == 0,
SmsEnabled = index == 1,
InAppEnabled = index == 1
}).ToList()
};
var updated = await service.UpdateAsync(request, 1);
Assert.Equal(2u, updated.RowVersion);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Empty(channels);
}
[Fact]
public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db, new[]
{
new NotificationConfigModule
{
ModuleKey = "sale.create",
EmailEnabled = true,
SmsEnabled = true,
InAppEnabled = false
}
});
var service = CreateService(db);
var channels = await service.ResolveEnabledChannelsAsync("sale.create");
Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
}
[Fact]
public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
{
var dbName = Guid.NewGuid().ToString("N");
await using var db = NotificationConfigTestSupport.CreateContext(dbName);
await NotificationConfigTestSupport.SeedAsync(db);
var service = CreateService(db, new ThrowingUnitOfWork());
var catalog = new NotificationModuleCatalog();
var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
{
Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
{
ModuleKey = module.Key,
EmailEnabled = false,
SmsEnabled = false,
InAppEnabled = false
}).ToList()
};
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(request, 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
{
var configRepo = new Repository<NotificationConfig>(db);
var moduleRepo = new Repository<NotificationConfigModule>(db);
return new NotificationConfigService(
configRepo,
moduleRepo,
new NotificationModuleCatalog(),
new TestCurrentUser(),
uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
}
}
@@ -0,0 +1,17 @@
using ERPCore.Services;
namespace ERPCore.Tests;
public class NotificationModuleCatalogTests
{
[Fact]
public void GetAll_ReturnsStableUniqueModuleKeys()
{
var catalog = new NotificationModuleCatalog();
Assert.NotEmpty(catalog.GetAll());
Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
}
@@ -0,0 +1,420 @@
using System.Reflection;
using System.Security.Claims;
using ERPCore.Controllers;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Search;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Caching.Memory;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Permission coverage (Phase B): every API endpoint that changes data sits behind a permission, every permission the
/// controllers name actually exists in the seeded catalogue, HR data is gated, global search respects the role,
/// state-changing cookie requests need the CSRF token, and a branch-less person can't read every branch.
/// </summary>
public class PermissionCoverageTests
{
// The seeded (HasData) rows live in the design-time model; the runtime model drops them.
private static IEnumerable<IDictionary<string, object?>> Seed<T>(ErpDbContext db) =>
Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService<IDesignTimeModel>(db)
.Model.FindEntityType(typeof(T))!.GetSeedData();
// ---------------------------------------------------------------- controllers <-> permissions
private static IEnumerable<Type> ApiControllers() =>
typeof(ApiControllerBase).Assembly.GetTypes()
.Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t));
private static IEnumerable<string> NavPolicyNames(MemberInfo member) =>
member.GetCustomAttributes<AuthorizeAttribute>(inherit: true)
.Select(a => a.Policy)
.Where(p => p is not null && p.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))!;
private static bool IsReadOnly(MethodInfo m) =>
m.GetCustomAttributes<HttpGetAttribute>().Any() && !m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any(a => a is not HttpGetAttribute);
/// <summary>Endpoints that deliberately sit behind the login door only: personal, or checked inside the service.</summary>
private static readonly HashSet<string> OpenByDesign = new()
{
nameof(AuthController), // sign-in, sessions, password: the person's own
nameof(NavController), // "what may I see": the person's own permissions
nameof(NotificationsController), // the person's own inbox
nameof(SupportTicketsController), // anyone may raise a ticket; the service scopes by owner/admin
nameof(TrackingController), // read-only serial/batch pickers used inside sales and stock screens
nameof(SearchController), // gated per result type inside GlobalSearchService
nameof(MetaController),
};
[Fact]
public void EveryDataChangingEndpoint_IsBehindAPermission()
{
var uncovered = new List<string>();
foreach (var controller in ApiControllers().Where(c => !OpenByDesign.Contains(c.Name)))
{
var classCovered = NavPolicyNames(controller).Any();
foreach (var action in controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any()))
{
if (IsReadOnly(action)) continue;
if (action.GetCustomAttributes<AllowAnonymousAttribute>().Any()) continue;
if (!classCovered && !NavPolicyNames(action).Any()) uncovered.Add($"{controller.Name}.{action.Name}");
}
}
Assert.True(uncovered.Count == 0, "Data-changing endpoints with no Nav permission: " + string.Join(", ", uncovered));
}
[Fact]
public void EveryControllerAboutSensitiveData_IsGatedAsAWhole()
{
// HR (salaries, bank accounts, loans, payslips) and the audit trail used to be open to every signed-in person.
foreach (var name in new[]
{
"AttendanceBatchesController", "LeaveRequestsController", "PayrollRunsController", "PayslipsController",
"HrReportsController", "EmployeesController", "SalaryComponentsController", "TaxSlabsController",
"PayrollStatutorySettingsController", "AuditLogsController",
})
{
var type = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == name);
Assert.True(NavPolicyNames(type).Any(), $"{name} has no class-level Nav permission.");
}
}
[Fact]
public void PayData_NeedsThePayrollPermission_OnTopOfEmployees()
{
var employees = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "EmployeesController");
foreach (var action in new[] { "ListBankDetails", "GetSalaryStructureHistory", "ListLoans", "GetLoan", "CreateLoan", "CreateSalaryStructure", "ReplaceBankDetails" })
{
var method = employees.GetMethod(action)!;
Assert.Contains(NavPolicyNames(method), p => p.StartsWith(NavPolicies.Prefix + "hrm.payroll", StringComparison.Ordinal));
}
var reports = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "HrReportsController");
foreach (var action in new[] { "PayrollRegister", "SalaryHistory" })
Assert.Contains(NavPolicyNames(reports.GetMethod(action)!), p => p == NavPolicies.Prefix + "hrm.payroll");
}
[Fact]
public void EveryNavCodeTheControllersName_ExistsInTheSeededCatalogue()
{
// The old Nav:vendors policy named a code that no longer existed, so only administrators got through.
using var db = CreateContext();
var seeded = Seed<Permission>(db)
.Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length))
.ToHashSet(StringComparer.Ordinal);
var missing = new SortedSet<string>();
foreach (var controller in ApiControllers())
{
var names = NavPolicyNames(controller).Concat(
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).SelectMany(NavPolicyNames));
foreach (var name in names)
{
var rest = name[NavPolicies.Prefix.Length..];
var sep = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
var code = sep >= 0 && Enum.TryParse<RolePermissionAction>(rest[(sep + 1)..], out _) ? rest[..sep] : rest;
// A module code is satisfied by the module itself or any screen inside it (HasAccessAsync does the same).
if (!seeded.Contains(code) && !seeded.Any(s => s.StartsWith(code + ".", StringComparison.Ordinal))) missing.Add($"{controller.Name}: {code}");
}
}
Assert.True(missing.Count == 0, "Controllers name permissions that are not seeded: " + string.Join("; ", missing));
}
[Fact]
public void SeededCatalogue_HasNoDuplicateCodes_AndEverySubItemHasAPermission()
{
using var db = CreateContext();
var permissions = Seed<Permission>(db).ToList();
var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList();
Assert.Equal(codes.Count, codes.Distinct().Count());
var subItems = Seed<SubNavItem>(db);
var granted = permissions.Where(p => p[nameof(Permission.SubNavItemId)] is not null).Select(p => (int)p[nameof(Permission.SubNavItemId)]!).ToHashSet();
foreach (var sub in subItems)
Assert.True(granted.Contains((int)sub[nameof(SubNavItem.SubNavItemId)]!), $"No permission row for sub item {sub[nameof(SubNavItem.Code)]}.");
}
[Theory]
[InlineData("hrm")]
[InlineData("hrm.employees")]
[InlineData("hrm.payroll")]
[InlineData("sales-returns")]
[InlineData("settings.audit-logs")]
public void NewPermissions_AreSeeded(string code)
{
using var db = CreateContext();
var codes = Seed<Permission>(db).Select(d => (string)d[nameof(Permission.Code)]!);
Assert.Contains("NAV:" + code, codes);
}
// ---------------------------------------------------------------- the policies actually decide
private sealed class FakeUser(int? roleId, bool isAdmin = false, int? branchId = null, bool branching = true) : ICurrentUser
{
public int AuditUserId => 10;
public int? RoleId => roleId;
public string? RoleCode => null;
public bool IsAdmin => isAdmin;
public string UserId => "10";
public bool IsAuthenticated => true;
public int? BranchId => branchId;
public bool BranchingEnabled => branching;
}
private static async Task<(ErpDbContext Db, NavPermissionService Nav)> RolesAsync()
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = 1, Code = "Hr", Name = "HR officer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = 2, Code = "Clerk", Name = "Clerk", CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "hrm", Label = "HR" });
db.SubNavItems.AddRange(
new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "hrm.employees", Label = "Employees" },
new SubNavItem { SubNavItemId = 102, NavItemId = 100, Code = "hrm.payroll", Label = "Payroll" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:hrm.employees", SubNavItemId = 101 },
new Permission { PermissionId = 201, Code = "NAV:hrm.payroll", SubNavItemId = 102 });
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Edit });
await db.SaveChangesAsync();
return (db, new NavPermissionService(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions())));
}
private static async Task<bool> PassesAsync(NavPermissionService nav, ICurrentUser user, string policyName)
{
var provider = new NavPolicyProvider(Microsoft.Extensions.Options.Options.Create(new AuthorizationOptions()));
var policy = (await provider.GetPolicyAsync(policyName))!;
var requirement = policy.Requirements.OfType<NavRequirement>().Single();
var context = new AuthorizationHandlerContext(new[] { requirement }, new ClaimsPrincipal(new ClaimsIdentity("test")), null);
await new NavAuthorizationHandler(user, nav).HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task HrOfficer_ReachesEmployees_ButNotPayData_AndAnAdministratorReachesAll()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
var hr = new FakeUser(roleId: 1);
Assert.True(await PassesAsync(nav, hr, "Nav:hrm")); // the module, via a screen inside it
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees"));
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees:Edit"));
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.employees:Delete")); // not granted
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.payroll")); // salaries, loans, bank accounts
Assert.False(await PassesAsync(nav, hr, "Nav:settings.audit-logs"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: 2), "Nav:hrm"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: null), "Nav:hrm"));
Assert.True(await PassesAsync(nav, new FakeUser(roleId: 2, isAdmin: true), "Nav:hrm.payroll:Approve"));
}
// ---------------------------------------------------------------- global search follows the role
[Fact]
public async Task Search_OnlyReturnsKindsTheRoleMayOpen()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E-1", FullName = "Alice Perera", BranchId = Branch1 });
await db.SaveChangesAsync();
GlobalSearchService Search(ICurrentUser u) => new(
new Repository<Item>(db), new Repository<PurchaseOrder>(db), new Repository<SalesSlip>(db), new Repository<Employee>(db), u, nav);
var hr = await Search(new FakeUser(roleId: 1)).SearchAsync("Alice");
Assert.Contains(hr, r => r.Type == GlobalSearchResultType.Employee);
var clerk = await Search(new FakeUser(roleId: 2)).SearchAsync("Alice");
Assert.DoesNotContain(clerk, r => r.Type == GlobalSearchResultType.Employee);
var admin = await Search(new FakeUser(roleId: 2, isAdmin: true)).SearchAsync("Alice");
Assert.Contains(admin, r => r.Type == GlobalSearchResultType.Employee);
}
// ---------------------------------------------------------------- CSRF on every cookie-authenticated mutation
private static (CsrfProtectionFilter Filter, ActionExecutingContext Context) CsrfCall(
string method, string? cookie = "token", string? header = null, bool sessionCookie = true,
bool bearer = false, bool anonymousEndpoint = false, bool hasCsrfAttribute = false)
{
var http = new DefaultHttpContext();
http.Request.Method = method;
var cookies = new List<string>();
if (sessionCookie) cookies.Add("erp_at=jwt");
if (cookie is not null) cookies.Add($"XSRF-TOKEN={cookie}");
if (cookies.Count > 0) http.Request.Headers.Cookie = string.Join("; ", cookies);
if (header is not null) http.Request.Headers["X-XSRF-TOKEN"] = header;
if (bearer) http.Request.Headers.Authorization = "Bearer abc";
var metadata = new List<object>();
if (anonymousEndpoint) metadata.Add(new AllowAnonymousAttribute());
if (hasCsrfAttribute) metadata.Add(new ValidateCsrfAttribute());
http.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "test"));
var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor());
var context = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object?>(), new object());
return (new CsrfProtectionFilter(), context);
}
private static async Task<bool> RunAsync(CsrfProtectionFilter filter, ActionExecutingContext context)
{
var ran = false;
await filter.OnActionExecutionAsync(context, () =>
{
ran = true;
return Task.FromResult(new ActionExecutedContext(context, context.Filters, context.Controller));
});
return ran;
}
[Theory]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("PATCH")]
[InlineData("DELETE")]
public async Task CookieRequest_WithoutTheCsrfHeader_IsRefused(string method)
{
var (filter, context) = CsrfCall(method, cookie: "token", header: null);
var ex = await Assert.ThrowsAsync<DomainException>(() => RunAsync(filter, context));
Assert.Equal(ErrorCodes.CsrfTokenMismatch, ex.Code);
Assert.Equal(403, ex.StatusCode);
}
[Fact]
public async Task CookieRequest_WithAWrongOrMissingCsrfCookie_IsRefused()
{
var (f1, c1) = CsrfCall("POST", cookie: "token", header: "other");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, header: "token");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f2, c2));
}
[Fact]
public async Task CookieRequest_WithMatchingCsrfHeader_Runs()
{
var (filter, context) = CsrfCall("DELETE", cookie: "token", header: "token");
Assert.True(await RunAsync(filter, context));
}
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
[InlineData("OPTIONS")]
public async Task SafeMethods_AreNotChecked(string method)
{
var (filter, context) = CsrfCall(method, cookie: null);
Assert.True(await RunAsync(filter, context));
}
[Fact]
public async Task BearerCallers_AnonymousEndpoints_AndRequestsWithoutASessionCookie_AreNotChecked()
{
var (f1, c1) = CsrfCall("POST", cookie: null, bearer: true);
Assert.True(await RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, anonymousEndpoint: true);
Assert.True(await RunAsync(f2, c2));
var (f3, c3) = CsrfCall("POST", cookie: null, sessionCookie: false);
Assert.True(await RunAsync(f3, c3));
}
[Fact]
public async Task ActionsThatCarryTheirOwnCsrfAttribute_AreNotCheckedTwice()
{
var (filter, context) = CsrfCall("POST", cookie: null, hasCsrfAttribute: true);
Assert.True(await RunAsync(filter, context)); // the attribute itself enforces it
}
[Fact]
public void TheCsrfFilter_IsRegisteredGlobally()
{
var program = File.ReadAllText(Path.Combine(FindBackendRoot(), "ERPCore", "Program.cs"));
Assert.Contains("Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>()", program);
}
private static string FindBackendRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "ERPCore"))) dir = dir.Parent;
return dir?.FullName ?? throw new DirectoryNotFoundException("Backend root not found.");
}
// ---------------------------------------------------------------- a branch-less person can't read every branch
[Fact]
public void BranchContext_AdministratorsAreNeverScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, isAdmin: true, branchId: 5));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public void BranchContext_AnEmployeeIsScopedToTheirOwnBranch()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: 5));
Assert.True(ctx.IsActive);
Assert.Equal(5, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_InABranchingDeployment_SeesNoBranchAtAll()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: true));
Assert.True(ctx.IsActive);
Assert.Equal(CurrentBranchContext.NoBranchId, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_WhenBranchingIsOff_IsNotScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: false));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public async Task QueryFilters_HideEveryWarehouse_FromABranchlessNonAdmin_ButNotFromAnAdminOrWhenBranchingIsOff()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
async Task<int> CountFor(ICurrentUser user, bool seed)
{
await using var db = new ErpDbContext(options, user, new CurrentBranchContext(user));
if (seed)
{
db.Warehouses.AddRange(
new Warehouse { WarehouseId = 1, Code = "W1", Name = "One", BranchId = 1 },
new Warehouse { WarehouseId = 2, Code = "W2", Name = "Two", BranchId = 2 });
await db.SaveChangesAsync();
}
return db.Warehouses.Count();
}
Assert.Equal(2, await CountFor(new FakeUser(1, isAdmin: true), seed: true));
Assert.Equal(1, await CountFor(new FakeUser(1, branchId: 2), seed: false));
Assert.Equal(0, await CountFor(new FakeUser(1, branchId: null, branching: true), seed: false));
Assert.Equal(2, await CountFor(new FakeUser(1, branchId: null, branching: false), seed: false));
}
}
@@ -0,0 +1,840 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances.
/// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale
/// or hand-built request sends.
/// </summary>
public class ProcurementAndOpeningBalanceTests
{
// ---------------------------------------------------------------- helpers
private static async Task SeedProcurementConfigAsync(
ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false,
bool terms = true, bool taxReg = true)
{
db.ProcurementConfig.Add(new ProcurementConfig
{
ConfigId = ProcurementConfig.SingletonId,
VendorContactMode = mode,
VendorAddressEnabled = address,
PoTaxEnabled = tax,
VendorPaymentTermsEnabled = terms,
VendorTaxRegEnabled = taxReg
});
await db.SaveChangesAsync();
}
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
new(new Repository<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? tweak = null)
{
var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private sealed class NullNotifications : INotificationService
{
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask;
}
// ---------------------------------------------------------------- vendors
[Fact]
public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.None);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.Vendors);
}
[Fact]
public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value;
Assert.Equal("+94 77 123 4567", created.Mobile);
Assert.Null(created.Email);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
[Theory]
[InlineData("abc")]
[InlineData("12")]
public async Task Vendor_InvalidMobile_IsRejected(string mobile)
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile)));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Vendor_Address_NeedsTheSwitchOn()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, address: false);
var off = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road")));
Assert.Equal(ErrorCodes.ConfigDisabled, off.Code);
db.ProcurementConfig.Single().VendorAddressEnabled = true;
await db.SaveChangesAsync();
var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value;
Assert.Equal("12 Galle Road", created.Address);
}
[Fact]
public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r =>
{
r.Email = "accounts@supplier.lk";
r.Address = "Colombo";
}))).Value;
// Contact mode switched off later: an update must not erase what was recorded.
db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on
var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r =>
{
r.Terms = " NET30 ";
r.TaxReg = "134567890-7000";
}))).Value;
Assert.Equal("NET30", created.Terms);
Assert.Equal("134567890-7000", created.TaxReg);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, terms: false, taxReg: false);
var terms = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
var taxReg = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123")));
Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code);
Assert.Empty(db.Vendors);
// Blank is fine: the form simply doesn't send them.
var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value;
Assert.Null(ok.Terms);
}
[Fact]
public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r =>
{
r.Terms = "NET45";
r.TaxReg = "TRN-1";
}))).Value;
var cfg = db.ProcurementConfig.Single();
cfg.VendorPaymentTermsEnabled = false;
cfg.VendorTaxRegEnabled = false;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg));
}
// ---------------------------------------------------------------- purchase-order tax
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
new(new Repository<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
ConfigService(db), new PassThroughUnitOfWork(db));
private static async Task<int> SeedVendorAndItemAsync(ErpDbContext db)
{
db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 });
await db.SaveChangesAsync();
return 1;
}
private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new()
{
VendorId = 1,
BranchId = Branch1,
SaveAsDraft = true,
Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }]
};
[Fact]
public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value;
var line = Assert.Single(po.Lines);
Assert.Equal(180m, line.TaxAmount);
Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal));
Assert.Equal(180m, db.PoLines.Single().TaxAmount);
}
[Fact]
public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
// 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding).
var request = PoRequest(0.05m);
request.Lines[0].Qty = 3;
request.Lines[0].UnitPrice = 33.5m;
var po = (await PoSvc(db).CreateAsync(request)).Value;
Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount);
}
[Fact]
public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: false);
await SeedVendorAndItemAsync(db);
var ex = await Assert.ThrowsAsync<DomainException>(() => PoSvc(db).CreateAsync(PoRequest(0.18m)));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.PurchaseOrders);
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.Equal(0m, po.Totals.Tax);
}
[Fact]
public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false);
await SeedVendorAndItemAsync(db);
var vendor = db.Vendors.Single();
vendor.Mobile = "0771234567";
vendor.Email = "po@vendor.lk";
vendor.Address = "Colombo";
await db.SaveChangesAsync();
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.NotNull(po.Vendor);
Assert.Equal("PO Vendor", po.Vendor!.Name);
Assert.Equal("po@vendor.lk", po.Vendor.Email);
Assert.Null(po.Vendor.Mobile); // contact mode is Email
Assert.Null(po.Vendor.Address); // address switched off
}
// ---------------------------------------------------------------- purchase-order list: date filter + sort
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
private static async Task<int[]> SeedListAsync(ErpDbContext db)
{
db.Vendors.AddRange(
new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active },
new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 });
PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new()
{
PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft,
CreatedAt = createdUtc,
Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }]
};
db.PurchaseOrders.AddRange(
Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50
Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300
Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100
await db.SaveChangesAsync();
return [1, 2, 3];
}
private static async Task<List<int>> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null)
{
var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to);
return page.Items.Select(i => i.PoId).ToList();
}
[Fact]
public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
// PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it.
Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1)));
Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2)));
Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10)));
Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30)));
Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3)));
}
[Fact]
public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first
Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first
Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first)
Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor"));
Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50
Assert.Equal([1, 3, 2], await ListIds(svc, "total"));
Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default
}
[Fact]
public async Task PoList_Search_MatchesVendorNameAndCode()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null);
var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null);
Assert.Equal([2], byName.Items.Select(i => i.PoId));
Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId));
}
// ---------------------------------------------------------------- opening balances
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
private sealed class OpeningGl : IGeneralLedgerService
{
public List<GlJournalEntryRequest> Posted { get; } = new();
public int AccountCreates { get; private set; }
public bool EquityExists { get; init; } = true;
public string BankCurrency { get; init; } = "LKR";
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
AccountCreates++;
return Task.FromResult(new GeneralLedgerResponse
{
StatusCode = 201,
Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}"""
});
}
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlBankAccount>>(
[
new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"),
new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency)
]);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlAccount>>(EquityExists
? [new("3000", "Opening Balance Equity", 3, true, true)]
: [new("1000", "Bank", 1, false, true)]);
}
private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) =>
new(new Repository<OpeningBalanceRun>(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new()
{
CutoverDate = date,
Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList()
};
private static readonly DateOnly OpeningDate = new(2026, 4, 1);
[Fact]
public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m)));
var entry = Assert.Single(gl.Posted);
Assert.Equal("OPENING_BALANCE", entry.JournalType);
Assert.Equal("OPENING", entry.SourceModule);
Assert.Equal(OpeningDate, entry.EntryDate);
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m });
Assert.Equal(2000m, run.TotalAmount);
Assert.Equal("JV-2026-000001", run.GlJournalNo);
Assert.Equal(2, run.Lines.Count);
Assert.Equal(0, gl.AccountCreates); // the equity account already existed
}
[Fact]
public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone()
{
await using var db = CreateContext();
var gl = new OpeningGl { EquityExists = false };
await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
Assert.Equal(1, gl.AccountCreates);
Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m);
}
[Fact]
public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m))));
Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code);
Assert.Equal(409, ex.StatusCode);
Assert.Single(gl.Posted);
Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount);
}
[Fact]
public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m)));
var entry = Assert.Single(gl.Posted);
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m });
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Equal(200m, run.TotalAmount);
}
[Fact]
public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint()
{
await using var db = CreateContext();
var svc = OpeningSvc(db, new OpeningGl());
var before = await svc.GetCashBankAsync();
Assert.Null(before.Posted);
Assert.All(before.Accounts, a => Assert.Null(a.Amount));
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m)));
var after = await svc.GetCashBankAsync();
Assert.NotNull(after.Posted);
Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount);
Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount);
}
[Fact]
public async Task Opening_RejectsBadInput_WithoutPostingAnything()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
// not a cash/bank account
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
// more than 2 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
// nothing but zeros
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
// the same account twice
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
// in the future
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m))));
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
}
[Fact]
public async Task Opening_RefusesANonBaseCurrencyAccount()
{
await using var db = CreateContext();
var gl = new OpeningGl { BankCurrency = "USD" };
var ex = await Assert.ThrowsAsync<DomainException>(
() => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m))));
Assert.Equal(ErrorCodes.Validation, ex.Code);
Assert.Empty(gl.Posted);
}
// ---------------------------------------------------------------- opening stock
private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl)
{
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService(
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
new TestCurrentUser(), new PassThroughUnitOfWork(db));
}
private static Item StockedItem(int id, Action<Item>? tweak = null)
{
var item = new Item
{
ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1,
StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active
};
tweak?.Invoke(item);
return item;
}
private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new()
{
CutoverDate = OpeningDate,
Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList()
};
[Fact]
public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m)));
Assert.Equal(330m, run.TotalValue);
Assert.Equal(2, run.LineCount);
var layer = db.StockLayers.Single(l => l.ItemId == 1);
Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost));
var row = db.StockLedger.Single(l => l.ItemId == 1);
Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance));
Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId);
// One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row.
var entry = Assert.Single(gl.Posted);
Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType));
Assert.Collection(entry.Lines,
l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN"));
}
[Fact]
public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 });
await db.SaveChangesAsync();
var gl = new OpeningGl();
var ex = await Assert.ThrowsAsync<DomainException>(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m))));
Assert.Equal(409, ex.StatusCode);
Assert.Contains("SKU-1", ex.Message);
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item
}
[Fact]
public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await svc.PostAsync(StockRequest((1, 5m, 2m)));
await svc.PostAsync(StockRequest((2, 7m, 4m)));
Assert.Equal(2, gl.Posted.Count);
Assert.Equal(2, (await svc.ListAsync()).Count);
// ...but the same item cannot be opened a second time.
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m))));
}
[Fact]
public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers()
{
await using var db = CreateContext();
db.Items.AddRange(
StockedItem(1, i => i.TrackingMode = TrackingMode.Serial),
StockedItem(2, i => i.TrackingMode = TrackingMode.Batch),
StockedItem(3, i => i.StockNature = StockNature.Service),
StockedItem(4, i => i.Status = EntityStatus.Inactive),
StockedItem(5));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate
Assert.Empty(gl.Posted);
Assert.Empty(db.StockLayers);
}
[Fact]
public async Task OpeningStock_ZeroCostRun_PostsNoJournal()
{
await using var db = CreateContext();
db.Items.Add(StockedItem(1));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m)));
Assert.Equal(0m, run.TotalValue);
Assert.Null(run.GlJournalNo);
Assert.Empty(gl.Posted);
Assert.Equal(10m, db.StockLayers.Single().QtyRemaining);
}
// ---------------------------------------------------------------- purchase-order rejection
[Fact]
public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
// Somebody else raised it: the approver can't be its creator.
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
var rejected = await svc.RejectAsync(po.PoId, "Wrong price");
Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status);
// A rejected order is reopened like a draft: it can be sent for approval again...
var resubmitted = await svc.SubmitAsync(po.PoId);
Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status);
}
[Fact]
public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
await svc.RejectAsync(po.PoId, null);
await svc.DeleteAsync(po.PoId);
Assert.Empty(db.PurchaseOrders);
}
// ---------------------------------------------------------------- General Ledger payload
private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient
{
public string? SentBody { get; private set; }
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct)
{
if (requestBody is not null)
{
using var reader = new StreamReader(requestBody);
SentBody = await reader.ReadToEndAsync(ct);
}
return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body };
}
}
private static GlJournalEntryRequest GrnJournal() => new()
{
PeriodId = 1,
EntryDate = new DateOnly(2026, 10, 18),
SourceModule = "GRN",
Reference = "GRN-1",
Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)]
};
[Fact]
public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing()
{
// GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model
// validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out.
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None);
Assert.Equal("J-1", result.JournalNo);
Assert.DoesNotContain("journalType", client.SentBody!);
Assert.DoesNotContain("null", client.SentBody!);
}
[Fact]
public async Task GlPost_KeepsAJournalTypeWhenOneIsSet()
{
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var request = GrnJournal();
request.JournalType = "OPENING_BALANCE";
await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None);
Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!);
}
[Fact]
public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection()
{
var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}";
var client = new CapturingGlClient(400, problem);
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code);
Assert.Contains("JournalType", ex.Message);
Assert.Contains("required", ex.Message);
}
[Fact]
public async Task GlPost_GlsOwnMessage_StillWins()
{
var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}");
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal("Period is locked", ex.Message);
}
// ---------------------------------------------------------------- stock movement vs. document journal
[Fact]
public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
var now = DateTime.UtcNow;
// A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one,
// or the movement is counted twice in the General Ledger.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now,
CancellationToken.None, postGlJournal: false);
await db.SaveChangesAsync();
Assert.Empty(gl.Posted);
Assert.Single(db.StockLedger);
// Production and transfers have no document journal, so they keep the generic one.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now,
CancellationToken.None);
var journal = Assert.Single(gl.Posted);
Assert.Equal("STOCK", journal.SourceModule);
}
}
@@ -0,0 +1,124 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Production;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Production;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests;
/// <summary>
/// Covers the 412 translation on the two production services, which save through
/// <c>ExecuteInTransactionAsync</c> rather than a bare <c>SaveChangesAsync</c> and so guard
/// concurrency differently from the inline pattern the other services use.
/// </summary>
public class ProductionConcurrencyTests
{
private static readonly InMemoryDatabaseRoot Root = new();
[Fact]
public async Task TemplateUpdateAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
// Validation runs before the transaction opens, so the referenced item has to exist.
db.Items.Add(new Item
{
ItemId = 1,
Sku = "FG-1",
Name = "Finished good",
CategoryId = 1,
BaseUomId = 1,
Status = EntityStatus.Active
});
await db.SaveChangesAsync();
var service = CreateTemplateService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateAsync(1, SingleStageTemplate(), expectedRowVersion: 1));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
[Fact]
public async Task RunUpdateStageQuantitiesAsync_MapsConcurrencyFailuresToDomainException()
{
await using var db = CreateContext();
var service = CreateRunService(db);
var ex = await Assert.ThrowsAsync<DomainException>(
() => service.UpdateStageQuantitiesAsync(1, 1, new UpdateStageQuantitiesRequest()));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>
/// The smallest graph the validator accepts: one stage (both entry and terminal) with a
/// stock input and a single item-bearing output, and no edges.
/// </summary>
private static SaveTemplateRequest SingleStageTemplate() => new()
{
Code = "TPL-1",
Name = "Template 1",
Stages =
[
new SaveStageRequest
{
Key = "tmp-1",
Name = "Mix",
Inputs =
[
new SaveInputRequest
{
Source = StageInputSource.Stock,
ItemId = 1,
QtyUnit = StageQtyUnit.Pack,
QtyPerBatch = 1m
}
],
Outputs =
[
new SaveOutputRequest { Key = "tmp-o1", Name = "Output", ItemId = 1, QtyPerBatch = 1m }
]
}
]
};
private static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
private static ProductionTemplateService CreateTemplateService(ErpDbContext db)
=> new(
new Repository<ProductionTemplate>(db), new Repository<TemplateStage>(db),
new Repository<StageInput>(db), new Repository<StageOutput>(db),
new Repository<StageEdge>(db), new Repository<Item>(db),
new Repository<Uom>(db), new Repository<ProductionRun>(db),
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
// The costing, measurement and numbering collaborators are only ever touched inside the
// transaction lambda, which ThrowingTransactionUnitOfWork never invokes.
private static ProductionRunService CreateRunService(ErpDbContext db)
=> new(
new Repository<ProductionRun>(db), new Repository<ProductionTemplate>(db),
new Repository<Warehouse>(db), new Repository<Bin>(db),
new Repository<Item>(db), new Repository<ReasonCode>(db),
fifo: null!, measure: null!, numbers: null!,
new ThrowingTransactionUnitOfWork(), new TestCurrentUser());
}
+97
View File
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Stock;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>The Stock Ledger screen: item name/SKU/category/brand on every row, category and brand filters, and a running
/// balance that is the real balance after each movement (not whatever a posting stored).</summary>
public class StockLedgerTests
{
private static StockService Svc(ErpDbContext db) =>
new(null!, new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<StockTransferLine>(db),
new Repository<Warehouse>(db), new Repository<Item>(db));
private static StockLedger Row(int id, int item, int warehouse, Direction dir, decimal qty, decimal unitCost, decimal storedBalance = 0m) => new()
{
LedgerId = id, ItemId = item, WarehouseId = warehouse, Direction = dir, QtyBase = qty, UnitCost = unitCost,
Value = qty * unitCost, RunningBalance = storedBalance, SourceDocType = "T", SourceDocId = id, UserId = 1,
CreatedAt = new DateTime(2026, 10, 18, 4, 0, 0, DateTimeKind.Utc).AddMinutes(id)
};
private static async Task<ErpDbContext> SeedAsync()
{
var db = CreateContext();
db.Categories.AddRange(new Category { CategoryId = 1, Name = "Beverages" }, new Category { CategoryId = 2, Name = "Snacks" });
db.Brands.AddRange(new Brand { BrandId = 1, Name = "Milo" }, new Brand { BrandId = 2, Name = "Munchee" });
db.Items.AddRange(
new Item { ItemId = 1, Sku = "MILO-240", Name = "Milo 240ml", CategoryId = 1, BrandId = 1, BaseUomId = 1 },
new Item { ItemId = 2, Sku = "BISC-1", Name = "Biscuit", CategoryId = 2, BrandId = 2, BaseUomId = 1 });
db.StockLedger.AddRange(
Row(1, 1, Warehouse1, Direction.In, 100m, 200m, storedBalance: 100m),
Row(2, 1, Warehouse1, Direction.Out, 30m, 200m, storedBalance: 0m), // a sales issue: stored balance was never filled in
Row(3, 1, Warehouse1, Direction.In, 50m, 210m, storedBalance: 120m),
Row(4, 1, Warehouse2, Direction.In, 10m, 200m, storedBalance: 10m),
Row(5, 2, Warehouse1, Direction.In, 5m, 40m, storedBalance: 5m));
await db.SaveChangesAsync();
return db;
}
private static Task<PagedResponse<ERPCore.Dtos.Stock.StockLedgerRowDto>> Ledger(
ErpDbContext db, int page = 1, int pageSize = 50, int? itemId = null, int? warehouseId = null, int? categoryId = null, int? brandId = null) =>
Svc(db).GetLedgerAsync(itemId, warehouseId, null, null, null, null, null,
new PageQuery { Page = page, PageSize = pageSize }, CancellationToken.None, categoryId, brandId);
[Fact]
public async Task RunningBalance_IsTheRealBalanceAfterEachMovement_PerItemAndWarehouse()
{
await using var db = await SeedAsync();
var rows = (await Ledger(db)).Items.ToDictionary(r => r.LedgerId);
Assert.Equal(100m, rows[1].RunningBalance);
Assert.Equal(70m, rows[2].RunningBalance); // the stored 0 is ignored
Assert.Equal(120m, rows[3].RunningBalance);
Assert.Equal(10m, rows[4].RunningBalance); // another warehouse starts from its own zero
Assert.Equal(5m, rows[5].RunningBalance); // another item too
}
[Fact]
public async Task RunningBalance_IsStillRight_OnALaterPage_AndWithFiltersApplied()
{
await using var db = await SeedAsync();
// Newest first, 2 per page: page 2 holds ledger 3 and 2, whose balances depend on rows before and after it.
var page2 = (await Ledger(db, page: 2, pageSize: 2)).Items;
Assert.Equal(new long[] { 3, 2 }, page2.Select(r => (long)r.LedgerId));
Assert.Equal(new[] { 120m, 70m }, page2.Select(r => r.RunningBalance));
// Filtering to one warehouse and item changes which rows show, not what the balance is.
var only = (await Ledger(db, itemId: 1, warehouseId: Warehouse1)).Items.OrderBy(r => r.LedgerId).ToList();
Assert.Equal(new[] { 100m, 70m, 120m }, only.Select(r => r.RunningBalance));
}
[Fact]
public async Task EveryRow_CarriesTheItemsSkuNameCategoryAndBrand()
{
await using var db = await SeedAsync();
var row = (await Ledger(db, itemId: 1)).Items.First();
Assert.Equal(("MILO-240", "Milo 240ml", "Beverages", "Milo"), (row.ItemSku, row.ItemName, row.CategoryName, row.BrandName));
}
[Fact]
public async Task CategoryAndBrandFilters_NarrowTheLedgerToThoseItems()
{
await using var db = await SeedAsync();
Assert.All((await Ledger(db, categoryId: 2)).Items, r => Assert.Equal(2, r.ItemId));
Assert.Equal(4, (await Ledger(db, brandId: 1)).Items.Count);
Assert.Empty((await Ledger(db, categoryId: 2, brandId: 1)).Items);
}
}
@@ -0,0 +1,172 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Tests.TestSupport;
/// <summary>Records every journal it is asked to post; can be told to reject them instead.</summary>
internal sealed class FakeGeneralLedger : IGeneralLedgerService
{
public const long CashAccountId = 1, BankAccountId = 2, OtherBankAccountId = 3;
public List<GlJournalEntryRequest> Posted { get; } = new();
public bool Reject { get; set; }
public IReadOnlyList<GlBankAccount> Accounts { get; init; } =
[
new("Cash", CashAccountId, "Main Till", null, "Petty", null, 101, "1000-01", "LKR"),
new("Bank", BankAccountId, "BOC Current", "Bank of Ceylon", null, "0001234567", 102, "1010-01", "LKR"),
new("Bank", OtherBankAccountId, "HNB Savings", "Hatton National Bank", null, "9876543210", 103, "1010-02", "LKR")
];
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
if (Reject) throw new DomainException(ErrorCodes.Validation, "GL rejected the entry.", 502);
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JE-{Posted.Count}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
/// <summary>Not implemented on purpose — nothing here picks a GL account by code; see
/// <see cref="StubGeneralLedgerService"/> for the fake that does.</summary>
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException();
}
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
internal sealed class PassThroughUnitOfWork(ErpDbContext db) : IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
public async Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
await action(ct);
await db.SaveChangesAsync(ct);
}
public async Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
{
var result = await action(ct);
await db.SaveChangesAsync(ct);
return result;
}
}
internal sealed class FakeNumberSequence : INumberSequenceService
{
private int _next;
public Task<string> NextAsync(string docType, int branchId, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
public Task<string> NextAsync(string docType, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
}
/// <summary>Branching on: the requested branch is used as-is.</summary>
internal sealed class FakeBranchConfig : IBranchConfigService
{
public Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<ETagged<BranchConfigDto>> UpdateAsync(UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
public Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
=> requestedBranchId > 0 ? Task.FromResult(requestedBranchId) : throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
public Task<bool> IsWarehouseBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
public Task<int> ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default) => Task.FromResult(requestedBranchId);
public Task<int> ResolveDefaultBranchIdAsync(CancellationToken ct = default) => Task.FromResult(1);
}
/// <summary>Seeds and services for the cash-in-hand / bank-deposit tests. Two branches, one warehouse each.</summary>
internal static class BankDepositTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public const int Branch1 = 1, Branch2 = 2;
public const int Warehouse1 = 1, Warehouse2 = 2;
public static BusinessDayClock Clock { get; } = new(Config());
public static IConfiguration Config(DateOnly? trackingStart = null) => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DayEndReport:TimeZone"] = "Asia/Colombo",
["BankDeposit:DefaultCashAccountCode"] = "1000-01",
["BankDeposit:CashTrackingStartDate"] = trackingStart?.ToString("yyyy-MM-dd")
})
.Build();
/// <summary>Midday on a local business day, as the UTC instant documents are stamped with.</summary>
public static DateTime At(DateOnly day) => Clock.ToUtc(day).AddHours(6);
public static ErpDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
.Options;
var currentUser = new TestCurrentUser();
var db = new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
db.Branches.AddRange(
new Branch { BranchId = Branch1, Code = "BR-001", Name = "Colombo" },
new Branch { BranchId = Branch2, Code = "BR-002", Name = "Kandy" });
db.Warehouses.AddRange(
new Warehouse { WarehouseId = Warehouse1, Code = "WH-1", Name = "Colombo Store", BranchId = Branch1 },
new Warehouse { WarehouseId = Warehouse2, Code = "WH-2", Name = "Kandy Store", BranchId = Branch2 });
db.SaveChanges();
return db;
}
private static int _id;
public static SalesSlip CashSlip(DateOnly day, decimal cash, decimal card = 0m, int warehouseId = Warehouse1)
{
var id = Interlocked.Increment(ref _id);
return new SalesSlip
{
SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = At(day), Status = SalesSlipStatus.Posted, WarehouseId = warehouseId,
GrandTotal = cash + card, PaidAmount = cash + card, CashAmount = cash, CardAmount = card
};
}
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
{
var currentUser = new TestCurrentUser();
return new BankDepositService(
new Repository<BankDeposit>(db), new Repository<Branch>(db), CashService(db, trackingStart), gl,
currentUser, new CurrentBranchContext(currentUser), new FakeNumberSequence(), new FakeBranchConfig(),
new PassThroughUnitOfWork(db), db, Clock);
}
public static BankDeposit Deposit(DateOnly businessDate, decimal amount, int branchId = Branch1,
BankDepositStatus status = BankDepositStatus.Posted)
{
var id = Interlocked.Increment(ref _id);
return new BankDeposit
{
BankDepositId = id, DocNo = $"BD-{id}", BranchId = branchId, BusinessDate = businessDate, DepositDate = businessDate,
Amount = amount, Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current", CreatedBy = User.SystemUserId
};
}
}
@@ -0,0 +1,66 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Tests.TestSupport;
/// <summary>
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
/// doing so fails loudly rather than against a silently empty fake.
/// </summary>
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
{
private readonly IReadOnlyList<GlAccount> _accounts;
private readonly Exception? _throws;
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
public int Calls { get; private set; }
/// <summary>A postable, active account — the ordinary case.</summary>
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
{
Calls++;
return _throws is not null
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
: Task.FromResult(_accounts);
}
public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
=> throw new NotSupportedException();
public static DomainException Unreachable() =>
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
}
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
{
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
=> throw new NotSupportedException();
}
@@ -0,0 +1,100 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace ERPCore.Tests.TestSupport;
internal sealed class TestCurrentUser : ICurrentUser
{
public string UserId => "system";
public int AuditUserId => User.SystemUserId;
public bool IsAuthenticated => false;
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public int? RoleId => null;
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;
}
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new NotSupportedException();
}
/// <summary>
/// Fails the transactional path the way a lost optimistic-concurrency race does, so the
/// services that save through <c>ExecuteInTransactionAsync</c> can be covered too. The
/// lambda is never invoked, which is the point: the real <c>UnitOfWork</c> issues its own
/// <c>SaveChangesAsync</c> after the lambda returns, so the catch under test has to sit
/// outside it.
/// </summary>
internal sealed class ThrowingTransactionUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
{
public Task<int> SaveChangesAsync(CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
public Task<T> ExecuteInTransactionAsync<T>(Func<CancellationToken, Task<T>> action, CancellationToken ct = default)
=> throw new DbUpdateConcurrencyException("Test concurrency conflict.");
}
internal static class NotificationConfigTestSupport
{
private static readonly InMemoryDatabaseRoot Root = new();
public static ErpDbContext CreateContext(string name)
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(name, Root)
.Options;
// The real CurrentBranchContext over an unauthenticated user yields IsActive=false,
// which leaves the branch query filters off: what a unit test wants.
var currentUser = new TestCurrentUser();
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
}
public static async Task SeedAsync(ErpDbContext db, IEnumerable<NotificationConfigModule>? modules = null)
{
db.Users.Add(new User
{
UserId = User.SystemUserId,
Username = "system",
DisplayName = "System"
});
var config = new NotificationConfig
{
NotificationConfigId = NotificationConfig.SingletonId,
UpdatedBy = User.SystemUserId,
UpdatedAt = DateTime.UtcNow,
// No RowVersion here on purpose: ErpDbContext.BumpRowVersions stamps every
// inserted row with 1, so any seeded value is overwritten on save.
};
if (modules is not null)
{
foreach (var module in modules)
{
module.NotificationConfigId = NotificationConfig.SingletonId;
config.Modules.Add(module);
}
}
db.NotificationConfig.Add(config);
await db.SaveChangesAsync();
}
}
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Access configuration — the default role for people who have none. Both reading and changing it need the
/// <c>settings</c> nav permission (it decides what a brand-new sign-in can do).
/// </summary>
[Route("api/v1/access-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class AccessConfigController : ApiControllerBase
{
private readonly IAccessConfigService _config;
public AccessConfigController(IAccessConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<AccessConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<AccessConfigDto>> Update([FromBody] UpdateAccessConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Approvals;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
/// signed-in user — it only lists what they may approve and their own requests. Reading the
/// settings is open too (screens need to know whether prices are locked); changing them needs
/// <c>settings.approvals</c>.
/// </summary>
[Route("api/v1/approvals")]
public sealed class ApprovalsController : ApiControllerBase
{
private readonly ApprovalInboxService _inbox;
private readonly IApprovalService _approvals;
public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
{
_inbox = inbox;
_approvals = approvals;
}
[HttpGet("pending")]
[ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<PendingApprovalsDto>> Pending(CancellationToken ct)
=> Ok(await _inbox.PendingAsync(ct));
[HttpGet("config")]
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ApprovalConfigDto>> GetConfig(CancellationToken ct)
=> Ok(await _approvals.GetConfigAsync(ct));
[HttpPut("config")]
[Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
[ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ApprovalConfigDto>> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
=> Ok(await _approvals.UpdateConfigAsync(request, ct));
}
@@ -1,6 +1,8 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// the audit trail is required (AR-01 compensating control) and read access is the
/// only way to consume it.
/// </summary>
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
[Route("api/v1/audit-logs")]
public sealed class AuditLogsController : ApiControllerBase
{
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
}
/// <summary>
@@ -45,8 +47,7 @@ public sealed class AuthController : ControllerBase
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
return Ok(await _roles.GetMeAsync(ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
@@ -68,6 +69,7 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
@@ -78,9 +80,13 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Accounts;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
/// Recording needs the Create action on <c>accounts.bank-deposits</c>; cancelling needs Delete.
/// </summary>
[Route("api/v1/bank-deposits")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
public sealed class BankDepositsController : ApiControllerBase
{
private readonly IBankDepositService _deposits;
private readonly ICashInHandService _cash;
public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
{
_deposits = deposits;
_cash = cash;
}
/// <summary>Deposits filtered by business date range, branch, bank account and status, plus their total.</summary>
[HttpGet]
[ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BankDepositListDto>> List(
[FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
[FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
=> Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
[HttpGet("{bankDepositId:int}")]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BankDepositDto>> GetById(int bankDepositId, CancellationToken ct)
{
var result = await _deposits.GetAsync(bankDepositId, ct);
return result is null ? NotFound() : Ok(result);
}
/// <summary>One business day's cash position (defaults to today) and how much of it can still be deposited.</summary>
[HttpGet("cash-in-hand")]
[ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashInHandDto>> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _cash.GetPositionAsync(date, branchId, ct));
/// <summary>Day-wise cash position for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
[HttpGet("cash-in-hand/daily")]
[ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CashInHandDailyDto>> CashInHandDaily(
[FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
/// <summary>Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.</summary>
[HttpPost]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BankDepositDto>> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
{
var result = await _deposits.CreateAsync(request, ct);
return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
}
/// <summary>Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.</summary>
[HttpPost("{bankDepositId:int}/cancel")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BankDepositDto>> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
=> Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Branch configuration endpoints — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only, same
/// Phase-1 posture as <see cref="ProductConfigController"/> — any ERP-admitted user can
/// flip this flag until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/branch-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class BranchConfigController : ApiControllerBase
{
private readonly IBranchConfigService _config;
public BranchConfigController(IBranchConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<BranchConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(BranchConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchConfigDto>> Update(
[FromBody] UpdateBranchConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
[Route("api/v1/brands")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/bundle-sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class BundleSalesController : ApiControllerBase
{
private readonly IBundleSaleService _bundles;
@@ -29,6 +32,14 @@ public sealed class BundleSalesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPost("templates")]
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status201Created)]
public async Task<ActionResult<BundleSaleTemplateDto>> CreateTemplate([FromBody] CreateBundleSaleTemplateRequest request, CancellationToken ct)
{
var result = await _bundles.CreateTemplateAsync(request, ct);
return Created($"/api/v1/bundle-sales/templates/{result.BundleSaleTemplateId}", result);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
/// </summary>
[Route("api/v1/categories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class CategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
[Route("api/v1/customers")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
@@ -20,8 +23,10 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -6,6 +8,7 @@ namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
@@ -15,6 +18,6 @@ public sealed class DashboardController : ApiControllerBase
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
}
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Reports;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).</summary>
[Route("api/v1/day-end-report")]
[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
public sealed class DayEndReportController : ApiControllerBase
{
private readonly IDayEndReportService _reports;
public DayEndReportController(IDayEndReportService reports) => _reports = reports;
/// <summary>One business day (defaults to today in the report's time zone).</summary>
[HttpGet]
[ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DayEndReportDto>> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _reports.GetDayAsync(date, branchId, ct));
/// <summary>Day-wise figures for <c>from</c>..<c>to</c> inclusive (at most 92 days).</summary>
[HttpGet("daily")]
[ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<DayEndDailyReportDto>> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
/// anyone with Accounts access (the expense form needs the lists); changes need the
/// Expense Setup permission. Categories are the fixed seeded four — there is no create
/// endpoint; only their active flag can change. Titles can be added under them.
/// </summary>
[Route("api/v1")]
[Authorize(Policy = NavPolicies.Prefix + "accounts")]
public sealed class ExpenseSetupController : ApiControllerBase
{
private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
private readonly IExpenseSetupService _setup;
public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
/// <summary>Categories, each with its titles, in display order.</summary>
[HttpGet("expense-categories")]
[ProducesResponseType(typeof(IReadOnlyList<ExpenseCategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ExpenseCategoryDto>>> List([FromQuery] bool includeInactive, CancellationToken ct)
=> Ok(await _setup.ListAsync(includeInactive, ct));
[HttpPut("expense-categories/{expenseCategoryId:int}")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseCategoryDto>> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
=> Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
[HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseTitleDto>> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
{
var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
}
[HttpPut("expense-titles/{expenseTitleId:int}")]
[Authorize(Policy = SetupPolicy)]
[ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseTitleDto>> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
=> Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
}
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Expenses;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).</summary>
[Route("api/v1/expenses")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
public sealed class ExpensesController : ApiControllerBase
{
private readonly IExpenseService _expenses;
private readonly IExpensePaymentService _payments;
public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
{
_expenses = expenses;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ExpenseSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ExpenseSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
[FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] bool overdueOnly, CancellationToken ct)
=> Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
/// <summary>Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.</summary>
[HttpGet("summary")]
[ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ExpenseSummaryReportDto>> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _expenses.GetSummaryAsync(from, to, ct));
[HttpGet("{expenseId:int}")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ExpenseDto>> GetById(int expenseId, CancellationToken ct)
{
var result = await _expenses.GetAsync(expenseId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ExpenseDto>> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
{
var result = await _expenses.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
}
[HttpPut("{expenseId:int}")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ExpenseDto>> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Draft only.</summary>
[HttpDelete("{expenseId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int expenseId, CancellationToken ct)
{
await _expenses.DeleteAsync(expenseId, ct);
return NoContent();
}
/// <summary>Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).</summary>
[HttpPost("{expenseId:int}/post")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> Post(int expenseId, CancellationToken ct)
=> Ok(await _expenses.PostAsync(expenseId, ct));
/// <summary>Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.</summary>
[HttpPost("{expenseId:int}/post-and-pay")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
/// <summary>Draft, or Posted with no payments (posts the reversing GL entry).</summary>
[HttpPost("{expenseId:int}/cancel")]
[ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ExpenseDto>> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
=> Ok(await _expenses.CancelAsync(expenseId, request, ct));
/// <summary>Pay against the balance, in full or in installments; one GL entry per submission.</summary>
[HttpPost("{expenseId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
{
var rows = await _payments.PayAsync(expenseId, request, ct);
return Created($"/api/v1/expenses/{expenseId}/payments", rows);
}
/// <summary>Payment history, newest first.</summary>
[HttpGet("{expenseId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<ExpensePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ExpensePaymentDto>>> ListPayments(int expenseId, CancellationToken ct)
=> Ok(await _payments.ListAsync(expenseId, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
@@ -29,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
[HttpPatch("{**path}")]
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
[HttpDelete("{**path}")]
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
@@ -0,0 +1,53 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Role → GL account mapping endpoints.
/// <para>
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
/// and the server-side validation work from the same list.
/// </para>
/// </summary>
[Route("api/v1/gl-account-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class GlAccountConfigController : ApiControllerBase
{
private readonly IGlAccountConfigService _config;
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
/// not be reached, since a mapping that could not be verified is not stored.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<GlAccountConfigDto>> Update(
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// GRN configuration endpoints — warehouse selection and the default receiving warehouse.
/// <para>
/// <b>Authorization:</b> gated on the Receiving nav policy (the New GRN form has to read it), same
/// Phase-1 posture as <see cref="SalesConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/grn-config")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnConfigController : ApiControllerBase
{
private readonly IGrnConfigService _config;
public GrnConfigController(IGrnConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GrnConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(GrnConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<GrnConfigDto>> Update(
[FromBody] UpdateGrnConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
+10 -3
View File
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
@@ -8,19 +10,24 @@ namespace ERPCore.Controllers;
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
[Route("api/v1/grns")]
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
public sealed class GrnsController : ApiControllerBase
{
private readonly IGrnService _grns;
public GrnsController(IGrnService grns) => _grns = grns;
public GrnsController(IGrnService grns)
{
_grns = grns;
}
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +66,15 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
}
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,4 +68,15 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
}
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
}
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -83,7 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +101,19 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
{
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
return Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -103,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -112,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -130,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -137,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -153,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -167,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -176,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
@@ -196,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -205,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
return File(content, contentType, fileName);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
}
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
}
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
}
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,11 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
}
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +67,15 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Item photos. Same access as the item master itself.</summary>
[Route("api/v1/items/{itemId:int}/images")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemImagesController : ApiControllerBase
{
private readonly IItemImageService _images;
private readonly ICurrentUser _currentUser;
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
{
_images = images;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
=> Ok(await _images.ListAsync(itemId, ct));
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
[HttpPost]
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
}
/// <summary>The image bytes, served inline so an &lt;img&gt; can point straight at it.</summary>
[HttpGet("{itemImageId:int}/content")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
{
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
Response.Headers.CacheControl = "private, max-age=86400";
return File(content, contentType);
}
[HttpDelete("{itemImageId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
{
await _images.DeleteAsync(itemId, itemImageId, ct);
return NoContent();
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
/// into the client-generated SKU (docs/10 Part C.9).
/// </summary>
[Route("api/v1/item-types")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Items;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
[Route("api/v1/items")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ItemsController : ApiControllerBase
{
private readonly IItemService _items;
@@ -27,6 +30,40 @@ public sealed class ItemsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
[HttpGet("overview")]
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
[FromQuery] StockNature? stockNature,
[FromQuery] ItemStockFilter? stock,
CancellationToken ct)
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
[HttpGet("scan")]
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
{
var result = await _items.ScanAsync(code, itemId, ct);
return result is null ? NotFound() : Ok(result);
}
/// <summary>The item's latest posted GRN receipts (default 5, newest first): unit cost, discount, net cost, vendor.</summary>
[HttpGet("{itemId:int}/purchase-prices")]
[ProducesResponseType(typeof(IReadOnlyList<ItemPurchasePriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ItemPurchasePriceDto>>> PurchasePrices(int itemId, [FromQuery] int take, CancellationToken ct)
=> Ok(await _items.GetPurchasePricesAsync(itemId, take <= 0 ? 5 : take, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// Data only — no posting in Phase 1.
/// </summary>
[Route("api/v1/journal-entries")]
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
public sealed class JournalEntriesController : ApiControllerBase
{
private readonly IAuditService _audit;
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class NotificationConfigController : ApiControllerBase
{
private readonly INotificationConfigService _config;
public NotificationConfigController(INotificationConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<NotificationConfigDto>> Update([FromBody] UpdateNotificationConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notifications")]
public sealed class NotificationsController : ApiControllerBase
{
private readonly INotificationService _notifications;
private readonly ICurrentUser _currentUser;
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
{
_notifications = notifications;
_currentUser = currentUser;
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
{
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
return Ok(feed);
}
[HttpPost("mark-all-read")]
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
{
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
return NoContent();
}
}
@@ -0,0 +1,71 @@
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// One-time opening balances. Gated on the <c>accounts.opening-balances</c> nav code (Admin always passes;
/// other roles need it granted under Settings → Roles).
/// </summary>
[Route("api/v1/opening-balances")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.opening-balances")]
public sealed class OpeningBalancesController : ApiControllerBase
{
private readonly IOpeningBalanceService _openingBalances;
private readonly IOpeningStockService _openingStock;
public OpeningBalancesController(IOpeningBalanceService openingBalances, IOpeningStockService openingStock)
{
_openingBalances = openingBalances;
_openingStock = openingStock;
}
/// <summary>The cash and bank accounts to enter opening amounts for — or the posted record, once done.</summary>
[HttpGet("cash-bank")]
[ProducesResponseType(typeof(CashBankOpeningStateDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashBankOpeningStateDto>> GetCashBank(CancellationToken ct) =>
Ok(await _openingBalances.GetCashBankAsync(ct));
/// <summary>Posts the cash and bank opening balances as one journal. Allowed once; later calls get 409.</summary>
[HttpPost("cash-bank")]
[ProducesResponseType(typeof(OpeningBalanceRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningBalanceRunDto>> PostCashBank(
[FromBody] PostCashBankOpeningRequest request, CancellationToken ct)
{
var run = await _openingBalances.PostCashBankAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
/// <summary>Opening-stock runs posted so far, newest first.</summary>
[HttpGet("stock")]
[ProducesResponseType(typeof(IReadOnlyList<OpeningStockRunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<OpeningStockRunSummaryDto>>> ListStock(CancellationToken ct) =>
Ok(await _openingStock.ListAsync(ct));
/// <summary>One opening-stock run with its lines.</summary>
[HttpGet("stock/{runId:int}")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OpeningStockRunDto>> GetStock(int runId, CancellationToken ct)
{
var run = await _openingStock.GetAsync(runId, ct);
return run is null ? NotFound() : Ok(run);
}
/// <summary>
/// Posts opening stock: a FIFO layer per line and one journal (Dr Inventory / Cr Opening Balance Equity).
/// An item that already has stock movements in that warehouse is refused with 409.
/// </summary>
[HttpPost("stock")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningStockRunDto>> PostStock([FromBody] PostOpeningStockRequest request, CancellationToken ct)
{
var run = await _openingStock.PostAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
}
@@ -0,0 +1,77 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.OwnerReport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
/// configuration screens (BranchConfigController, GlAccountConfigController).
/// </summary>
[Route("api/v1/settings/owner-report")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class OwnerReportController : ApiControllerBase
{
// One test send per minute (whole deployment) so the button can't be used to spam the owner.
private static readonly SemaphoreSlim TestGate = new(1, 1);
private static DateTime _lastTestUtc = DateTime.MinValue;
private readonly IOwnerReportService _service;
public OwnerReportController(IOwnerReportService service) => _service = service;
[HttpGet]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportConfigDto>> Get(CancellationToken ct)
{
var result = await _service.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<OwnerReportConfigDto>> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
{
var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("preview")]
[ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
=> Ok(await _service.PreviewAsync(date, ct));
[HttpPost("test")]
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
{
await TestGate.WaitAsync(ct);
try
{
if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
return StatusCode(StatusCodes.Status429TooManyRequests,
new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
_lastTestUtc = DateTime.UtcNow;
}
finally
{
TestGate.Release();
}
return Ok(await _service.SendTestAsync(request?.Date, ct));
}
[HttpGet("dispatches")]
[ProducesResponseType(typeof(PagedResponse<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _service.ListDispatchesAsync(query, ct));
}
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Products;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
/// <c>products.price-adjustments</c>, deciding needs Approve; an item's applied price history is
/// readable by anyone who can see Products.
/// </summary>
[Route("api/v1/price-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class PriceAdjustmentsController : ApiControllerBase
{
private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
private readonly IPriceAdjustmentService _prices;
public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
[HttpGet]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PagedResponse<PriceAdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PriceAdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
=> Ok(await _prices.ListAsync(query, status, itemId, ct));
[HttpGet("{priceAdjustmentId:int}")]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PriceAdjustmentDto>> GetById(int priceAdjustmentId, CancellationToken ct)
{
var dto = await _prices.GetAsync(priceAdjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Request new selling prices for one or more items.</summary>
[HttpPost]
[Authorize(Policy = CreatePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
{
var dto = await _prices.CreateAsync(request, ct);
return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
}
/// <summary>Give the next approval; the final one applies the prices.</summary>
[HttpPost("{priceAdjustmentId:int}/approve")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
=> Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
[HttpPost("{priceAdjustmentId:int}/reject")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
=> Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
/// <summary>The requester withdraws a pending request.</summary>
[HttpPost("{priceAdjustmentId:int}/cancel")]
[Authorize(Policy = Screen)]
[ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PriceAdjustmentDto>> Cancel(int priceAdjustmentId, CancellationToken ct)
=> Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
/// <summary>Every applied price change for an item, newest first, with who approved it.</summary>
[HttpGet("/api/v1/items/{itemId:int}/price-history")]
[ProducesResponseType(typeof(IReadOnlyList<ItemPriceHistoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ItemPriceHistoryDto>>> ItemHistory(int itemId, CancellationToken ct)
=> Ok(await _prices.ItemHistoryAsync(itemId, ct));
}
@@ -0,0 +1,46 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Procurement configuration endpoints — optional vendor fields and PO tax.
/// <para>
/// <b>Authorization:</b> reading is open to any signed-in ERP user, because the vendor and purchase-order
/// forms must read the flags and those screens sit under different nav codes. The flags are not
/// sensitive. Changing them needs the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/procurement-config")]
public sealed class ProcurementConfigController : ApiControllerBase
{
private readonly IProcurementConfigService _config;
public ProcurementConfigController(IProcurementConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProcurementConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProcurementConfigDto>> Update(
[FromBody] UpdateProcurementConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
/// </para>
/// </summary>
[Route("api/v1/product-config")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
@@ -42,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
}}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
[Route("api/v1/production-runs")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
@@ -24,6 +27,19 @@ public sealed class ProductionRunsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
/// something, plus a summary total over every filtered row.</summary>
[HttpGet("produced")]
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
[FromQuery] ProductionRunStatus? status,
CancellationToken ct)
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -40,6 +56,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
@@ -53,6 +70,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
@@ -70,6 +88,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -82,6 +101,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -93,6 +113,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -104,6 +125,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -116,6 +138,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -126,6 +149,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -137,6 +161,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -149,6 +174,7 @@ public sealed class ProductionRunsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
[Authorize(Policy = NavPolicies.Prefix + "production")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
[Route("api/v1/purchase-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseInvoicesController : ApiControllerBase
{
private readonly IPurchaseInvoiceService _invoices;
private readonly IPurchaseInvoicePaymentService _payments;
public PurchaseInvoicesController(IPurchaseInvoiceService invoices, IPurchaseInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseInvoiceStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, vendorId, ct));
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
[HttpGet("eligible-grns")]
[ProducesResponseType(typeof(IReadOnlyList<GrnEligibleForInvoiceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<GrnEligibleForInvoiceDto>>> EligibleGrns([FromQuery] int vendorId, CancellationToken ct)
=> Ok(await _invoices.ListEligibleGrnsAsync(vendorId, ct));
[HttpGet("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseInvoiceDto>> GetById(int purchaseInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(purchaseInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PurchaseInvoiceDto>> Create([FromBody] CreatePurchaseInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/purchase-invoices/{result.Value.PurchaseInvoiceId}", result.Value);
}
[HttpPut("{purchaseInvoiceId:int}")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseInvoiceDto>> Update(int purchaseInvoiceId, [FromBody] UpdatePurchaseInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(purchaseInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{purchaseInvoiceId:int}/post")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Post(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(purchaseInvoiceId, ct));
[HttpPost("{purchaseInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoiceDto>> Cancel(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(purchaseInvoiceId, ct));
/// <summary>Pay the vendor against this invoice's balance, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(PurchaseInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseInvoicePaymentDto>> Pay(int purchaseInvoiceId, [FromBody] CreatePurchaseInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(purchaseInvoiceId, request, ct);
return Created($"/api/v1/purchase-invoices/{purchaseInvoiceId}/payments/{dto.PurchaseInvoicePaymentId}", dto);
}
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{purchaseInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<PurchaseInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<PurchaseInvoicePaymentDto>>> ListPayments(int purchaseInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(purchaseInvoiceId, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
[Route("api/v1/purchase-orders")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseOrdersController : ApiControllerBase
{
private readonly IPurchaseOrderService _pos;
@@ -17,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
[HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -32,7 +36,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -57,7 +61,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,6 +71,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -76,13 +81,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
[HttpPost("{poId:int}/approve")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
/// Same caller restrictions as <see cref="Approve"/>.</summary>
[HttpPost("{poId:int}/reject")]
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
[HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
[Route("api/v1/purchase-returns")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class PurchaseReturnsController : ApiControllerBase
{
private readonly IPurchaseReturnService _returns;
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _codes.ListAsync(context, query, ct));
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[HttpPost]
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
[Route("api/v1/requisitions")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RequisitionsController : ApiControllerBase
{
private readonly IRequisitionService _requisitions;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>RFQ &amp; quotation endpoints (docs/11 §3.2).</summary>
[Route("api/v1/rfqs")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class RfqsController : ApiControllerBase
{
private readonly IRfqService _rfqs;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
}
[HttpDelete("{roleId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
/// <para>
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
/// until per-endpoint RBAC lands.
/// </para>
/// </summary>
[Route("api/v1/sales-config")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesConfigController : ApiControllerBase
{
private readonly ISalesConfigService _config;
public SalesConfigController(ISalesConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesConfigDto>> Update(
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
[Route("api/v1/sales-day-end")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesDayEndController : ApiControllerBase
{
private readonly ISalesDayEndService _dayEnds;
public SalesDayEndController(ISalesDayEndService dayEnds) => _dayEnds = dayEnds;
/// <summary>List day-end closes, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesDayEndSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesDayEndSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? cashierUserId, CancellationToken ct)
=> Ok(await _dayEnds.ListAsync(query, cashierUserId, ct));
/// <summary>What closing this cashier/date right now would include (or the existing close's totals, if already closed).</summary>
[HttpGet("preview")]
[ProducesResponseType(typeof(SalesDayEndPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesDayEndPreviewDto>> Preview(
[FromQuery] int cashierUserId, [FromQuery] DateOnly? businessDate, CancellationToken ct)
=> Ok(await _dayEnds.PreviewAsync(cashierUserId, businessDate ?? DateOnly.FromDateTime(DateTime.UtcNow), ct));
[HttpGet("{salesDayEndId:int}")]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesDayEndDto>> GetById(int salesDayEndId, CancellationToken ct)
{
var dto = await _dayEnds.GetAsync(salesDayEndId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Close the day: lock every Posted slip for (CashierUserId, BusinessDate) and post the
/// consolidated GL journal entry. Idempotent — closing an already-closed date replays it.</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesDayEndDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesDayEndDto>> Close([FromBody] CreateSalesDayEndRequest request, CancellationToken ct)
{
var dto = await _dayEnds.CloseAsync(request, ct);
return Created($"/api/v1/sales-day-end/{dto.SalesDayEndId}", dto);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,11 +9,76 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
private readonly ISalesPricingService _pricing;
private readonly ISalesDomainService _sales;
private readonly IStockService _stock;
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
public SalesInvoicesController(
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
ISalesDomainService sales, IStockService stock)
{
_invoices = invoices;
_payments = payments;
_pricing = pricing;
_sales = sales;
_stock = stock;
}
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
// items (services) are never short, so they report IsStocked = false and no limit.
[HttpGet("stock-availability")]
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
bool isStocked;
try
{
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
}
catch (InvalidOperationException)
{
return NotFound();
}
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
}
// Feeds New Sale's "Add item" dropdown with each item's issuable stock at the sale's warehouse.
// Under "sales" for the same reason as above; items absent from the list have no stock there.
[HttpGet("stock-summary")]
[ProducesResponseType(typeof(IReadOnlyList<SalesItemStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesItemStockDto>>> StockSummary([FromQuery] int warehouseId, CancellationToken ct)
{
var available = await _stock.GetAvailableByItemAsync(warehouseId, ct);
return Ok(available.Select(kv => new SalesItemStockDto(kv.Key, kv.Value)).ToList());
}
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
// same resolution the server applies on save.
[HttpGet("suggested-price")]
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
try
{
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
}
catch (InvalidOperationException)
{
return NotFound();
}
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
@@ -20,8 +87,31 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
[FromQuery] string? q,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -70,4 +160,35 @@ public sealed class SalesInvoicesController : ApiControllerBase
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
/// <summary>Pay the customer's balance against this invoice, in full or in installments; posts a real GL journal entry.</summary>
[HttpPost("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(SalesInvoicePaymentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalesInvoicePaymentDto>> Pay(int salesInvoiceId, [FromBody] CreateSalesInvoicePaymentRequest request, CancellationToken ct)
{
var dto = await _payments.PayAsync(salesInvoiceId, request, ct);
return Created($"/api/v1/sales-invoices/{salesInvoiceId}/payments/{dto.SalesInvoicePaymentId}", dto);
}
/// <summary>
/// Pay several Posted invoices of one (non-cash) customer at once, split across payment lines;
/// applied oldest invoice first under a single GL journal entry. Returns one payment per split.
/// </summary>
[HttpPost("payments/bulk")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> PayBulk([FromBody] CreateBulkSalesInvoicePaymentRequest request, CancellationToken ct)
=> Ok(await _payments.PayBulkAsync(request, ct));
/// <summary>Payment history for this invoice, newest first.</summary>
[HttpGet("{salesInvoiceId:int}/payments")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoicePaymentDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<SalesInvoicePaymentDto>>> ListPayments(int salesInvoiceId, CancellationToken ct)
=> Ok(await _payments.ListAsync(salesInvoiceId, ct));
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
@@ -0,0 +1,52 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
[HttpGet("remaining")]
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (inbound movement).</summary>
[HttpPost]
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
{
var dto = await _returns.CreateAsync(request, ct);
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Search;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/search")]
public sealed class SearchController : ApiControllerBase
{
private readonly IGlobalSearchService _search;
public SearchController(IGlobalSearchService search) => _search = search;
[HttpGet]
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
{
var results = await _search.SearchAsync(q, 6, ct);
return Ok(results);
}
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
[Route("api/v1/serials")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class SerialsController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SerialSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
[HttpGet("lookup")]
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> Lookup([FromQuery] string serialNo, CancellationToken ct)
=> Ok(await _tracking.FindSerialAsync(serialNo, ct));
[HttpGet("{serialId:int}")]
[ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
{
var dto = await _tracking.GetSerialAsync(serialId, ct);
return dto is null ? NotFound() : Ok(dto);
}
}
@@ -1,3 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
@@ -5,22 +9,30 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
/// <summary>
/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
/// <c>stock.adjustments</c>; the two-person rules are enforced by the service.
/// </summary>
[Route("api/v1/stock-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
private readonly IAdjustmentService _adjustments;
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
/// <summary>List adjustments (posted and requests), newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
[FromQuery] AdjustmentStatus? status, CancellationToken ct, [FromQuery] bool wastage = false, [FromQuery] int? itemId = null)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct, wastage, itemId));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -30,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
/// <summary>Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -41,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
var dto = await _adjustments.CreateAsync(request, ct);
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
}
/// <summary>Give the next approval. The final one posts the stock movement and GL.</summary>
[HttpPost("{adjustmentId:int}/approve")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Approve(int adjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
=> Ok(await _adjustments.ApproveAsync(adjustmentId, request?.Comment, ct));
[HttpPost("{adjustmentId:int}/reject")]
[Authorize(Policy = ApprovePolicy)]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Reject(int adjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
=> Ok(await _adjustments.RejectAsync(adjustmentId, request.Reason, ct));
/// <summary>The requester withdraws a pending request.</summary>
[HttpPost("{adjustmentId:int}/cancel")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AdjustmentDto>> Cancel(int adjustmentId, CancellationToken ct)
=> Ok(await _adjustments.CancelAsync(adjustmentId, ct));
}
+25 -8
View File
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
@@ -8,17 +10,31 @@ namespace ERPCore.Controllers;
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
[Route("api/v1/stock")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
private readonly IReorderService _reorder;
private readonly IItemTrackingService _tracking;
public StockController(IStockService stock, IReorderService reorder)
public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{
_stock = stock;
_reorder = reorder;
_tracking = tracking;
}
/// <summary>
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
/// </summary>
[HttpGet("batches")]
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BatchStockDto>>> Batches(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
[HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
@@ -28,8 +44,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, branchId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
@@ -39,11 +55,12 @@ public sealed class StockController : ApiControllerBase
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[FromQuery] PageQuery query, CancellationToken ct,
[FromQuery] int? categoryId = null, [FromQuery] int? brandId = null)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct, categoryId, brandId));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +71,8 @@ public sealed class StockController : ApiControllerBase
[HttpGet("reorder-alerts")]
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
[FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _reorder.GetAlertsAsync(warehouseId, branchId, query, ct));
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
[Route("api/v1/stock-counts")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockCountsController : ApiControllerBase
{
private readonly ICountService _counts;
@@ -18,8 +21,8 @@ public sealed class StockCountsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, branchId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
[Route("api/v1/stock-transfers")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockTransfersController : ApiControllerBase
{
private readonly ITransferService _transfers;
@@ -19,8 +22,8 @@ public sealed class StockTransfersController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, branchId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// since a subcategory only exists in the context of one.
/// </summary>
[Route("api/v1/subcategories")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
@@ -0,0 +1,70 @@
using ERPCore.Dtos.Support;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/support-tickets")]
public sealed class SupportTicketsController : ApiControllerBase
{
private readonly ISupportTicketService _tickets;
private readonly ICurrentUser _currentUser;
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
{
_tickets = tickets;
_currentUser = currentUser;
}
[HttpPost]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
{
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
SetETag(result.RowVersion);
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
{
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
return Ok(list);
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
{
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{id:int}/messages")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut("{id:int}/status")]
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.TaxRates;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
[Route("api/v1/tax-rates")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class TaxRatesController : ApiControllerBase
{
private readonly ITaxRateService _taxRates;
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _taxRates.ListAsync(query, status, ct));
[HttpGet("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
{
var result = await _taxRates.GetAsync(taxRateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
{
var result = await _taxRates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
}
[HttpPut("{taxRateId:int}")]
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{taxRateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
{
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Serial/batch pickers used across GRN, sales, returns and stock screens
/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
/// only ERP access — not one module's nav permission — as every one of those screens calls them.
/// </summary>
[Route("api/v1/tracking")]
public sealed class TrackingController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
private readonly ISerialTrackingService _serialTracking;
public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
{
_tracking = tracking;
_serialTracking = serialTracking;
}
/// <summary>
/// Which tracking features are switched on (Product Configuration) — readable by every
/// screen, unlike <c>/product-config</c> which needs the Products permission.
/// </summary>
[HttpGet("settings")]
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
/// <summary>
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
/// uniqueness when it is saved and confirmed.
/// </summary>
[HttpGet("serials/next-numbers")]
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
[HttpGet("serials/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> AvailableSerials(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
=> Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
[HttpGet("batches/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
[HttpGet("batches/suggest")]
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
}
+17 -2
View File
@@ -1,3 +1,6 @@
using ERPCore.Domain.Enums;
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Uoms;
using ERPCore.Services.Interfaces;
@@ -7,6 +10,7 @@ namespace ERPCore.Controllers;
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
[Route("api/v1/uoms")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class UomsController : ApiControllerBase
{
private readonly IUomService _uoms;
@@ -15,8 +19,9 @@ public sealed class UomsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<UomDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _uoms.ListAsync(query, status, ct));
[HttpPost]
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
@@ -26,4 +31,14 @@ public sealed class UomsController : ApiControllerBase
var dto = await _uoms.CreateAsync(request, ct);
return Created($"/api/v1/uoms/{dto.UomId}", dto);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{uomId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
{
await _uoms.SetStatusAsync(uomId, request.Status, ct);
return NoContent();
}
}
+75 -4
View File
@@ -1,7 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -11,15 +17,20 @@ namespace ERPCore.Controllers;
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
_audit = audit;
_currentUser = currentUser;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
@@ -30,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -53,7 +64,7 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
var result = await _users.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
@@ -62,4 +73,64 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
[HttpPut("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateAsync(userId, request, ct));
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
[HttpPost("{userId:int}/send-reset-link")]
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
=> Ok(await _users.SendResetLinkAsync(userId, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
/// <summary>
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
/// </summary>
[HttpPost("purge-authhex-identity")]
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
}
/// <summary>
/// One-time migration helper (administrators only): copies each role-less user's role from AuthHex into the ERP.
/// Safe to repeat — users that already have a role are left alone. See
/// <see cref="IUserManagementService.BackfillRolesFromAuthHexAsync"/>.
/// </summary>
[HttpPost("backfill-roles")]
[ProducesResponseType(typeof(BackfillRolesResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<BackfillRolesResultDto>> BackfillRoles(CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can run the role backfill.", 403);
return Ok(await _users.BackfillRolesFromAuthHexAsync(ct));
}
}
@@ -1,13 +1,16 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
@@ -32,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -42,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -54,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,4 +68,15 @@ public sealed class VendorsController : ApiControllerBase
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
}
[HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int vendorId, CancellationToken ct)
{
await _vendors.DeleteAsync(vendorId, ct);
return NoContent();
}
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Warehouses;
using ERPCore.Services.Interfaces;
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
/// <summary>Warehouse &amp; bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
[Route("api/v1/warehouses")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
public sealed class WarehousesController : ApiControllerBase
{
private readonly IWarehouseService _warehouses;
@@ -15,8 +18,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
@@ -36,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
[HttpPut("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
[HttpDelete("{warehouseId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
{
await _warehouses.DeleteAsync(warehouseId, ct);
return NoContent();
}
[HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
/// posting a sale, never directly — this controller only reads.
/// </summary>
[Route("api/v1/warranties")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
public sealed class WarrantiesController : ApiControllerBase
{
private readonly IItemTrackingService _tracking;
public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> List(
[FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
[FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct));
}
@@ -0,0 +1,44 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Items;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
[Route("api/v1/warranty-periods")]
[Authorize(Policy = NavPolicies.Prefix + "products")]
public sealed class WarrantyPeriodsController : ApiControllerBase
{
private readonly IWarrantyPeriodService _periods;
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _periods.ListAsync(status, ct));
[HttpPost]
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
{
var result = await _periods.CreateAsync(request.Months!.Value, ct);
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
}
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{warrantyPeriodId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
{
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
return NoContent();
}
}

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