Compare commits

..

80 Commits

Author SHA1 Message Date
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 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
ImanThiyanga b2a218e2f8 Merge branch 'Dev' into fix/uom-overall 2026-08-12 05:32:39 +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 a7ba3d3e04 sku and item fix 2026-08-11 21:55:04 +05:30
ImanThiyanga 0ae80395cf Merge pull request 'Refactor production and stock tests to remove UOM dependency' (#38) from fix/uom-overall into Dev
Reviewed-on: #38
2026-08-11 06:03:30 +00:00
ImanThiyanga 15ddac178c Refactor production and stock tests to remove UOM dependency
- Updated production.spec.ts, stock-adjustments.spec.ts, and stock-transfers.spec.ts to eliminate UOM references in API seeder and test cases.
- Adjusted ApiSeeder methods to remove UOM parameters from stock receiving and production template creation.
- Revised documentation to reflect changes in UOM handling, emphasizing that stock is counted in base UOM only.
- Introduced new enums for MeasureUnit and StageQtyUnit to clarify content size and stage input quantities.
- Implemented ItemContent service to validate and normalize content sizes.
- Updated smoke tests to validate production stage inputs expressed in content units, ensuring correct consumption calculations.
- Modified frontend UOM label handling to reflect the removal of per-line UOMs in document lines.
2026-08-11 11:32:40 +05:30
ImanThiyanga d37824cecc fronted fix 2026-08-07 16:40:31 +05:30
ImanThiyanga 271c940640 created new hosted db update 2026-08-07 14:54:35 +05:30
ImanThiyanga f2825900aa Merge pull request 'Fix bundle sale service interface signature' (#36) from test/sales into Dev
Reviewed-on: #36
2026-08-07 06:18:32 +00:00
ImanThiyanga 6520930aeb migrations removed 2026-08-07 11:42:07 +05:30
DeepnaPooja a8c6b4cb5e Fix bundle sale service interface signature 2026-08-07 11:41:52 +05:30
ImanThiyanga 8e9974b735 Merge pull request 'fix: update toast messages and improve UI elements in purchase orders and stock pages' (#34) from ui-fixers-7/8 into Dev
Reviewed-on: #34
2026-08-07 05:17:04 +00:00
Sasanka c4e016c460 fix: update toast messages and improve UI elements in purchase orders and stock pages 2026-08-07 10:46:23 +05:30
ImanThiyanga 7e8418685c Merge pull request 'fix sales issues' (#33) from fix--Sales_issues into Dev
Reviewed-on: #33
2026-08-07 05:13:02 +00:00
DeepnaPooja cbc72ef830 fix sales issues 2026-08-05 17:03:49 +05:30
ImanThiyanga 4324ba1a96 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-08-05 16:16:50 +05:30
ImanThiyanga 1af16d3dec fix: update DefaultConnection string for development environment 2026-08-05 16:16:15 +05:30
ImanThiyanga f140959b43 Merge pull request 'feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments' (#32) from test/rebase into Dev
Reviewed-on: #32
2026-08-05 08:04:42 +00:00
ImanThiyanga c31e23c2b9 feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments
- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
2026-08-05 13:26:41 +05:30
ImanThiyanga 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
ImanThiyanga 8e24ed6375 Merge branch 'production' into Dev 2026-08-05 07:22:25 +00:00
ImanThiyanga 9f22026784 Merge pull request 'Fix/general ledger v01' (#30) from fix/general-ledger-v01 into Dev
Reviewed-on: #30
2026-08-05 07:22:12 +00:00
ImanThiyanga ef105302bd Merge pull request 'fix: map GL response integers to string enums for Cheque Management fields' (#29) from fix/general-ledger-v01 into production
Reviewed-on: #29
2026-08-05 07:20:28 +00:00
ImanThiyanga d7ee83828c Merge branch 'production' into fix/general-ledger-v01 2026-08-05 07:20:23 +00:00
HarithaRandunu eb7b2691df fix: map GL response integers to string enums for Cheque Management fields 2026-08-05 12:45:34 +05:30
ImanThiyanga 1a0fb4603e Merge pull request 'Dev' (#28) from Dev into production
Reviewed-on: #28
2026-08-05 06:59:22 +00:00
ImanThiyanga 5fc5ef59ac Merge pull request 'feat: enhance purchase orders management with delete and approve actions' (#27) from fixers-frontend-8/5 into Dev
Reviewed-on: #27
2026-08-05 06:58:46 +00:00
Sasanka d1fe164ea2 fix: resolve import conflict in purchase order detail page 2026-08-05 12:21:01 +05:30
Sasanka 02f47bd485 Merge branch 'fixers-frontend-8/5' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into fixers-frontend-8/5 2026-08-05 12:15:28 +05:30
Sasanka 45554ceb9a feat: enhance purchase orders management with delete and approve actions
- Added delete and approve functionality for purchase orders in the procurement dashboard.
- Integrated toast notifications for user feedback on actions.
- Updated the purchase orders table to include action buttons for viewing, editing, approving, and deleting orders.

refactor: improve stage editor panel UI and functionality

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

fix: streamline template builder page layout

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

feat: implement vendor code generation logic

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

refactor: improve stage editor panel UI and functionality

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

fix: streamline template builder page layout

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

feat: implement vendor code generation logic

- Created a new utility function to generate unique vendor codes based on the vendor name.
- Updated the vendor creation form to auto-generate vendor codes and display them to the user.
- Removed manual vendor code input requirement, improving user experience and reducing errors.
2026-08-05 10:56:05 +05:30
DeepnaPooja 26cf2a146a Merge pull request 'centralize sales flow' (#26) from fix-sales-module into Dev
Reviewed-on: #26
2026-08-04 11:58:10 +00:00
ImanThiyanga 38c7545413 removing migrations 2026-08-04 16:14:12 +05:30
Kalana B. Thilakarathna a414dfc4ea graphify update 2026-07-31 12:01:42 +05:30
Kalana B. Thilakarathna 3cccaf4c63 graphify added 2026-07-31 11:53:20 +05:30
ImanThiyanga b7bd8dca5c Merge pull request 'Dev' (#20) from Dev into production
Reviewed-on: #20
2026-07-31 05:24:39 +00:00
543 changed files with 200139 additions and 7682 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+10
View File
@@ -0,0 +1,10 @@
# Deployment assets execute on the Linux runner / VPS. Force LF no matter which OS commits them:
# a CRLF script fails with "/usr/bin/env: 'bash\r': No such file or directory".
*.sh text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile text eol=lf
.dockerignore text eol=lf
*.env.example text eol=lf
+509
View File
@@ -0,0 +1,509 @@
# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy
# (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active".
#
# Which jobs run for which event:
# PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck /
# scripts (only the sides the PR touches) → ci-ok
# PR merged into production → build-and-deploy (checks are skipped)
# PR merged into Dev, or closed unmerged → nothing (every job skipped)
# Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes
#
# Checks: `ci-ok` is the single required status check for branch protection. It passes when every
# check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*.
#
# Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions →
# start → verify), plus: only the side whose folder changed in the PR is deployed, the database is
# backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback).
# Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow".
# The test environment is on hold (deploy/on-hold/).
#
# Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but
# the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with
# ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a
# trusted team member.
name: CI/CD - ERP Core
on:
pull_request:
branches:
- Dev
- production
types:
- opened
- synchronize
- reopened
- closed
workflow_dispatch:
inputs:
deploy_backend:
description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)"
type: boolean
default: false
deploy_frontend:
description: "Deploy the frontend"
type: boolean
default: false
# One group for the whole file, never cancelled: a deploy (or its migration) must not be interrupted
# by a PR push. Superseded check runs therefore queue instead of being cancelled; with the runner at
# capacity 1 they would run one after another anyway.
concurrency:
group: erp-core-cicd
cancel-in-progress: false
jobs:
# ════════════════════════════ Pull-request checks ════════════════════════════
detect:
name: detect
if: github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: linux
outputs:
backend: ${{ steps.detect.outputs.backend }}
frontend: ${{ steps.detect.outputs.frontend }}
e2e: ${{ steps.detect.outputs.e2e }}
scripts: ${{ steps.detect.outputs.scripts }}
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
git log -1 --format='checked out %h %s'
- name: Detect changed sides
id: detect
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
run: bash deploy/scripts/ci-detect.sh
backend:
name: backend
needs: detect
if: needs.detect.outputs.backend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Build, test, pending model changes
run: bash deploy/scripts/ci-backend.sh
- name: Destructive migration scan
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: bash deploy/scripts/migration-scan.sh
frontend:
name: frontend
needs: detect
if: needs.detect.outputs.frontend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
# Checks only (no image): build-and-deploy builds its own image with `docker build` after the merge.
- name: Type-check, lint, build
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=2 --memory=2500m \
-e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \
-v "$PWD/Frontend/erp-system:/app$z" -v erp-npm-cache:/root/.npm -w /app node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit && npx eslint . && npm run build'
e2e-typecheck:
name: e2e-typecheck
needs: detect
if: needs.detect.outputs.e2e == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Type-check Playwright specs
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=1 --memory=768m \
-v "$PWD/Testing/e2e:/e2e$z" -v erp-npm-cache:/root/.npm -w /e2e node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit'
scripts:
name: scripts
needs: detect
if: needs.detect.outputs.scripts == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: bash -n + shellcheck
run: |
for f in deploy/scripts/*.sh; do bash -n "$f"; done
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
files=""; for f in deploy/scripts/*.sh; do files="$files /mnt/${f#deploy/}"; done
# shellcheck disable=SC2086
docker run --rm -v "$PWD/deploy:/mnt$z" koalaman/shellcheck:stable \
--severity=warning --external-sources --source-path=SCRIPTDIR $files
ci-ok:
name: ci-ok
needs: [detect, backend, frontend, e2e-typecheck, scripts]
if: always() && github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: linux
steps:
- name: All required checks passed or were skipped
env:
RESULTS: >-
detect=${{ needs.detect.result }}
backend=${{ needs.backend.result }}
frontend=${{ needs.frontend.result }}
e2e-typecheck=${{ needs.e2e-typecheck.result }}
scripts=${{ needs.scripts.result }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
failed=0
for r in $RESULTS; do
case "${r#*=}" in
success|skipped) echo "ok $r" ;;
*) echo "FAILED $r"; failed=1 ;;
esac
done
if [ "$BASE_REF" = production ]; then
case "$HEAD_REF" in
Dev|hotfix/*) ;;
*) echo "PRs into production must come from Dev or hotfix/* (this one is from '$HEAD_REF')"; failed=1 ;;
esac
fi
exit "$failed"
# ════════════════════════════ Production deploy ════════════════════════════
# No `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge, so the
# deploy would never run. The pre-merge gate is branch protection requiring ci-ok.
build-and-deploy:
name: Build & Deploy
runs-on: linux
# base.ref matters: without it a PR merged into Dev would deploy production.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'production')
env:
PUBLISH_DIR: /home/erp/core/publish
BUILD_DIR: /tmp/erp-deploy
SERVICE: erpcore.service
SERVICE_USER: erpuser
BACKEND_PORT: "5220"
DB_NAME: ERPCore
DB_USER: erpcore
BACKUP_DIR: /var/backups/erpcore
WEB_IMAGE: erp-web:latest
WEB_CONTAINER: erp-web
WEB_PORT: "3020"
# ── Backend runtime config ────────────────────────────────────────────────────────────
# There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the
# "Install unit + write config" step writes them into a systemd drop-in on every backend
# deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend.
# The two secrets (DB password, GL API key) come from Gitea secrets, never from this file.
AUTHHEX_BASE_URL: "http://127.0.0.1:5433"
GL_BASE_URL: "http://127.0.0.1:5433/api/v1/"
STORAGE_ROOT: /var/lib/erpcore/hr-documents
LOG_PATH: /var/log/erpcore/erpcore-.log
SEED_DEMO_DATA: "false"
# Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to
# restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs).
AUTH_REQUIRED_USER_TYPE: ""
AUTH_REQUIRED_ROLE: ""
steps:
- name: Checkout code
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
fetch() { git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$1"; }
git init -q .
# The exact merge commit, not the branch tip (a second merge may already have landed).
if [ -n "$MERGE_SHA" ] && fetch "$MERGE_SHA"; then :; else fetch production; fi
git checkout -qf FETCH_HEAD && git clean -ffdxq
# production as it was before this PR — the detect step compares against it.
if [ -n "$BASE_SHA" ]; then fetch "$BASE_SHA" || echo "could not fetch base $BASE_SHA — both sides will deploy"; fi
git log -1 --format='Deploying %h %s'
- name: Detect changes
id: changes
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
INPUT_BACKEND: ${{ inputs.deploy_backend }}
INPUT_FRONTEND: ${{ inputs.deploy_frontend }}
run: |
# A folder's git tree hash changes only when something inside it changed, so comparing
# production-before-the-PR with the merge result works for merge, squash and rebase merges.
changed() {
if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo true; return; fi
now="$(git rev-parse -q --verify "HEAD:$1" || true)"
before="$(git rev-parse -q --verify "$BASE_SHA:$1" || true)"
if [ "$now" != "$before" ]; then echo true; else echo false; fi
}
if [ "$EVENT" = workflow_dispatch ]; then
backend="${INPUT_BACKEND:-false}"
frontend="${INPUT_FRONTEND:-false}"
migrations="$backend" # unknown on a manual run → take the backup
else
backend="$(changed Backend/ERPCore)"
frontend="$(changed Frontend/erp-system)"
migrations="$(changed Backend/ERPCore/Migrations)"
fi
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "migrations=$migrations"
} >> "$GITHUB_OUTPUT"
echo "backend=$backend frontend=$frontend migrations=$migrations"
if [ "$backend" != true ] && [ "$frontend" != true ]; then echo "Nothing to deploy."; fi
# ── Backend ──────────────────────────────────────────────────────────────────────────────
- name: Restore
if: steps.changes.outputs.backend == 'true'
run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64
- name: Publish
if: steps.changes.outputs.backend == 'true'
run: |
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
--no-restore \
-o "$BUILD_DIR"
- name: Backup database
if: steps.changes.outputs.backend == 'true' && steps.changes.outputs.migrations == 'true'
run: |
mkdir -p "$BACKUP_DIR"
pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)"
file="$BACKUP_DIR/$DB_NAME-$(date -u +%Y%m%dT%H%M%SZ).dump"
runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" > "$file.partial"
mv "$file.partial" "$file"
echo "Backup: $file"
find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete
- name: Install unit + write config
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
run: |
# Config lives in this workflow's env: block, not in a file on the server. It is written
# to a systemd drop-in here, before the service is stopped, so a bad value fails the job
# while the OLD release is still serving.
case "$AUTHHEX_BASE_URL$GL_BASE_URL" in
*CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;;
esac
[ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; }
[ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; }
case "$DB_PASSWORD" in
*";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;;
esac
# "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere.
publish="$(realpath -m "$PUBLISH_DIR")"
for path in "$STORAGE_ROOT" "$LOG_PATH"; do
case "$path" in
/*) ;;
*) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
esac
case "$(realpath -m "$path")/" in
"$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
esac
echo "ok $path"
done
# The unit is installed from the repo, so editing deploy/systemd/erpcore.service and
# deploying is enough — no manual copy step on the server.
install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service
install -d -m 755 /etc/systemd/system/erpcore.service.d
conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
# Values are quoted: systemd would otherwise split a password containing spaces.
umask 077
# Only the listening URL. Everything else is app configuration and lands in
# appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host
# configuration, the one setting appsettings cannot reliably provide.
cat > "$conf" <<EOF
[Service]
Environment="ASPNETCORE_URLS=http://127.0.0.1:$BACKEND_PORT"
EOF
chmod 600 "$conf"
systemctl daemon-reload
# Idempotent: creates the multi-user.target.wants symlink so the app survives a reboot.
systemctl enable "$SERVICE"
echo "✅ unit + drop-in installed ($(grep -c '^Environment=' "$conf") settings; values not printed)"
# Data dirs must exist and belong to the service user (Serilog fails silently otherwise).
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STORAGE_ROOT" "$(dirname "$LOG_PATH")"
- name: Stop service
if: steps.changes.outputs.backend == 'true'
run: systemctl stop "$SERVICE" || true
- name: Copy to deploy folder
if: steps.changes.outputs.backend == 'true'
run: |
mkdir -p "$PUBLISH_DIR"
rm -rf "${PUBLISH_DIR:?}"/*
cp -r "$BUILD_DIR"/. "$PUBLISH_DIR"/
- name: Fix ownership and permissions
if: steps.changes.outputs.backend == 'true'
run: |
chown -R "$SERVICE_USER:$SERVICE_USER" "$PUBLISH_DIR"
find "$PUBLISH_DIR" -type d -exec chmod 755 {} +
find "$PUBLISH_DIR" -type f -exec chmod 644 {} +
chmod +x "$PUBLISH_DIR/ERPCore"
# Written AFTER "Fix ownership" so it keeps mode 640 (root:erpuser) instead of being
# flattened to 644 — it holds the DB password and the GL API key.
- name: Write appsettings.Production.json
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
run: |
# Deployed config lives in appsettings.Production.json, generated here from this
# workflow's env: block + the Gitea secrets. It is NOT in git (.gitignore ignores
# appsettings.*.json) and is rewritten on every backend deploy, which is why the
# publish folder being wiped does not matter. It MERGES over appsettings.json, so
# Auth:RsaPublicKeyXml, Issuer, Audience and FileStorage:MaxSizeBytes still come
# from the committed base file.
conf="$PUBLISH_DIR/appsettings.Production.json"
# python3 emits the JSON so a password containing " or \ is escaped correctly.
python3 - > "$conf" <<'PY'
import json, os
e = os.environ
print(json.dumps({
"ConnectionStrings": {
"DefaultConnection":
"Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s"
% (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]),
},
"AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]},
"GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]},
"Auth": {
"RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"],
"RequiredRoleCode": e["AUTH_REQUIRED_ROLE"],
},
"FileStorage": {"RootPath": e["STORAGE_ROOT"]},
"LogFile": {"Path": e["LOG_PATH"]},
"Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"},
}, indent=2))
PY
chown "root:$SERVICE_USER" "$conf"
chmod 640 "$conf"
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf"
echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)"
- name: Start service
if: steps.changes.outputs.backend == 'true'
run: |
systemctl reset-failed "$SERVICE" 2>/dev/null || true
systemctl start "$SERVICE" || echo "systemctl start returned an error — the verify step shows why"
# Baseline for the crash check in the verify step.
echo "RESTARTS_AT_START=$(systemctl show -p NRestarts --value "$SERVICE")" >> "$GITHUB_ENV"
- name: Verify backend
if: steps.changes.outputs.backend == 'true'
env:
HEALTH_TIMEOUT: "300" # migrations run at startup; a crash still fails within seconds
run: bash deploy/scripts/wait-healthy.sh
# ── Frontend (after the backend: a failed backend step stops the job here) ─────────────
- name: Build frontend image
if: steps.changes.outputs.frontend == 'true'
run: docker build -t "$WEB_IMAGE" Frontend/erp-system
- name: Stop & remove existing container
if: steps.changes.outputs.frontend == 'true'
run: |
docker stop "$WEB_CONTAINER" 2>/dev/null || true
docker rm "$WEB_CONTAINER" 2>/dev/null || true
- name: Start container
if: steps.changes.outputs.frontend == 'true'
run: |
docker run -d \
--name "$WEB_CONTAINER" \
-p "127.0.0.1:$WEB_PORT:3000" \
--memory=512m \
--restart unless-stopped \
"$WEB_IMAGE"
- name: Verify frontend
if: steps.changes.outputs.frontend == 'true'
run: |
for _ in $(seq 1 15); do
if curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; then
echo "✅ Frontend serving on 127.0.0.1:$WEB_PORT"
exit 0
fi
sleep 2
done
echo "❌ Frontend did not serve /login within 30s"
docker ps -a --filter "name=$WEB_CONTAINER" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker logs --tail 50 "$WEB_CONTAINER" || true
exit 1
- name: Remove old images
if: steps.changes.outputs.frontend == 'true'
run: |
docker image prune -f
docker builder prune -f --filter until=168h
+11 -9
View File
@@ -30,12 +30,14 @@ yarn-error.log*
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
# migrations add` after the initial 4 silently produced a migration git would
# never see, while the (tracked) snapshot's changes committed normally —
# so the snapshot kept claiming tables existed that no migration in git
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
# tables were missing from the actual database for exactly this reason.
# Migrations now stay tracked like any other source file — commit them.
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
Testing/e2e/playwright-report/
Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Python (Backend/smoke) ────────────────────────────────────────────
__pycache__/
*.pyc
@@ -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="10.0.9" />
<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,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(7u, 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, 7);
Assert.Equal(7u, 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, 7));
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,72 @@
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;
public string? RoleCode => null;
public bool IsAdmin => false;
}
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();
}
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;
return new ErpDbContext(options, new TestCurrentUser());
}
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,
RowVersion = 7,
};
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();
}
}
+14
View File
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore.Tests", "ERPCore.Tests\ERPCore.Tests.csproj", "{E451472F-550F-40A0-AE82-E24ECD6C647A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,18 @@ Global
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -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>
@@ -68,6 +70,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 +81,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,44 @@
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")]
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);
}
}
@@ -29,14 +29,23 @@ 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(
[FromQuery] PageQuery query,
[FromQuery] BundleSaleStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{bundleSaleId:int}")]
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
@@ -15,6 +15,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));
}
@@ -12,15 +12,18 @@ 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, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -83,7 +83,6 @@ 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>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
{
await _employees.DeleteAsync(employeeId, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[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();
}
}
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -0,0 +1,34 @@
using ERPCore.Dtos.Notifications;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/notification-config")]
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);
}
}
@@ -24,6 +24,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)]
@@ -0,0 +1,102 @@
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")]
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));
}
@@ -0,0 +1,50 @@
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")]
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);
}
}
@@ -10,8 +10,13 @@ namespace ERPCore.Controllers;
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
private readonly ISalesInvoicePaymentService _payments;
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
@@ -20,8 +25,9 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -70,4 +76,23 @@ 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>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));
}
@@ -0,0 +1,49 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/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);
}
}
@@ -17,8 +17,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[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, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -28,8 +28,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 +39,11 @@ 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));
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -54,8 +54,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")]
@@ -18,8 +18,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)]
@@ -19,8 +19,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)]
+25 -4
View File
@@ -1,6 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -15,11 +18,15 @@ 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 +37,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, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -53,7 +60,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 +69,18 @@ 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>Revokes/restores standing inside ERPCore only — 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>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));
}
@@ -15,8 +15,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)]
+6
View File
@@ -10,6 +10,7 @@ public static class DocumentTypes
public const string Rfq = "RFQ";
public const string PurchaseOrder = "PO";
public const string Grn = "GRN";
public const string PurchaseInvoice = "PINV";
public const string Transfer = "TRF";
public const string Adjustment = "ADJ";
public const string Count = "CNT";
@@ -20,4 +21,9 @@ public static class DocumentTypes
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
public const string BundleSale = "BND";
public const string BundleSaleTemplate = "TPL";
public const string SalesReturn = "SRET";
/// <summary>Cashier day-end close-out (Sales) — <c>DEND-2026-00001</c>.</summary>
public const string SalesDayEnd = "DEND";
}
+5 -2
View File
@@ -3,16 +3,19 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Required by
/// <see cref="Employee.BranchId"/>, optionally referenced by <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
/// <summary>Server-generated on create ("BR-001", "BR-002", ...) — never accepted from the client.</summary>
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public string? PhoneNumber { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch configuration — a singleton row (single-tenant, docs/00-CORE §1) gating whether
/// this deployment uses branches at all. When <see cref="BranchingEnabled"/> is off, the
/// Employee/Warehouse create forms stop asking for a branch and every new record is
/// silently assigned to the deployment's one default branch instead — schema-wise
/// <c>Employee.BranchId</c>/<c>Warehouse.BranchId</c> stay <c>NOT NULL</c> either way, only
/// the UI/UX requirement toggles. See <see cref="Services.BranchConfigService.ResolveBranchIdAsync"/>.
/// </summary>
public class BranchConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool BranchingEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -29,5 +29,11 @@ public class BundleSale
public DateTime? UpdatedAt { get; set; }
public int ConcurrencyStamp { get; set; }
/// <summary>Set once this bundle's business date is closed by <see cref="SalesDayEnd"/> — a
/// closed bundle sale is frozen (no further edits). Same role as <see cref="SalesSlip.DayEndId"/>;
/// a bundle sale is a cashier document exactly like a sales slip, just priced as a set.</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
}
@@ -12,8 +12,6 @@ public class BundleSaleLine
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
@@ -8,8 +8,6 @@ public class BundleSaleTemplateLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+5 -3
View File
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
@@ -50,7 +52,7 @@ public class Employee
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; }
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
+15
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -30,6 +35,16 @@ public class Grn
public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
/// <summary>The Purchase Invoice this GRN has been consolidated into, if any (null until
/// invoiced). Vendor settlement is tracked on the invoice, not per-GRN — see
/// <see cref="PurchaseInvoice"/>.</summary>
public int? PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
+7 -3
View File
@@ -25,9 +25,6 @@ public class GrnLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
@@ -61,4 +58,11 @@ public class GrnLine
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
/// <summary>
/// One row per received unit when <see cref="Item.Warranty"/> is
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
/// Empty for a non-warranty item.
/// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One warranty number captured against a single received unit of a warranty-tracked
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
/// </summary>
public class GrnLineWarrantyNumber
{
public int GrnLineWarrantyNumberId { get; set; }
public int GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public string WarrantyNo { get; set; } = string.Empty;
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
public int WarrantyPeriodMonths { get; set; }
}
+42 -5
View File
@@ -24,6 +24,12 @@ public class Item
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
/// <summary>
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
/// and every document line. Nothing converts, so this is the sole meaning of a
/// quantity and cannot be changed once the item has stock history.
/// </summary>
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
@@ -32,14 +38,46 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// Optional selling price range and flat price used by Sales only.
/// <c>null</c> values mean the field is not set. These values never enter costing/GRN/FIFO.
/// </summary>
public decimal? SalePrice { get; set; }
public decimal? SellingMinPrice { get; set; }
public decimal? SellingMaxPrice { get; set; }
public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds, as the user entered it — <c>500</c> with
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
/// for a 1.5 L one. Null (together with the other three) when the item has no
/// measurable content: a screw, a label, a service.
/// <para>
/// Content never affects stock — that is always a pack count. It exists so production
/// can express a formula in millilitres or grams and resolve it to packs
/// (see <c>IItemMeasure</c>).
/// </para>
/// <para>
/// A loose bulk item bought by weight is modelled the same way:
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
/// </para>
/// </summary>
public decimal? ContentQty { get; set; }
public MeasureUnit? ContentUnit { get; set; }
/// <summary>
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
/// </summary>
public decimal? ContentBaseQty { get; set; }
public MeasureUnit? ContentBaseUnit { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -50,5 +88,4 @@ public class Item
public uint RowVersion { get; set; }
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
}
+23 -2
View File
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// <para>
/// It does, however, carry one piece of meaning the client acts on:
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
@@ -21,6 +24,24 @@ public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
/// <para>
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
/// </para>
/// <para>
/// A client hint only: the server never reads it when writing an item. Each item's pair is
/// still validated and normalised on its own by <c>ItemContent</c>.
/// </para>
/// </summary>
public bool IsMeasurable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
/// admin can see a user's device/login history — AuthHex's own session listing only
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
/// active sessions" view (that would need AuthHex to expose one).
/// </summary>
public class LoginActivity
{
public int LoginActivityId { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
public string? DeviceLabel { get; set; }
public DateTime LoginAt { get; set; }
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Notification configuration (new settings module) - singleton root row.
/// </summary>
public class NotificationConfig
{
public const int SingletonId = 1;
public int NotificationConfigId { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
public ICollection<NotificationConfigModule> Modules { get; set; } = new List<NotificationConfigModule>();
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Entities;
public class NotificationConfigModule
{
public int NotificationConfigModuleId { get; set; }
public int NotificationConfigId { get; set; }
public NotificationConfig? NotificationConfig { get; set; }
public string ModuleKey { get; set; } = string.Empty;
public bool EmailEnabled { get; set; }
public bool SmsEnabled { get; set; }
public bool InAppEnabled { get; set; }
}
@@ -15,9 +15,6 @@ public class PoLine
public int ItemId { get; set; }
public Item? Item { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is advisory only
/// - items carry no item-type reference (see <see cref="ItemType"/>), so there is nothing
/// on a write to reject; the frontend honours it by hiding the variant builder.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// on item writes and drive the create/edit screens. Reads are never gated, so existing
/// data stays visible after a flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public bool SellingPricesEnabled { get; set; } = true;
public bool SerialNumbersEnabled { get; set; } = true;
public bool BatchListsEnabled { get; set; } = true;
public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,50 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Vendor invoice consolidating one or more Confirmed GRNs for the same vendor
/// (each linked GRN becomes one <see cref="PurchaseInvoiceLine"/>, not re-itemized).
/// Draft is editable (GRNs can be re-selected); Posted reclassifies the GRN clearing
/// balance to Accounts Payable (see PurchaseInvoiceService.PostAsync); vendor payments
/// are then recorded against this aggregate rather than against the individual GRNs.
/// </summary>
public class PurchaseInvoice
{
public int PurchaseInvoiceId { get; set; }
public string DocNo { get; set; } = string.Empty;
public string VendorInvoiceNo { get; set; } = string.Empty;
public DateTime VendorInvoiceDate { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public string VendorSnapshotName { get; set; } = string.Empty;
public string? VendorSnapshotTaxNo { get; set; }
public PurchaseInvoiceStatus Status { get; set; } = PurchaseInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the AP-recognition GL journal entry posted when this
/// invoice is Posted (Debit GRN Clearing / Credit Accounts Payable) — see
/// PurchaseInvoiceService.PostAsync.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<PurchaseInvoiceLine> Lines { get; set; } = new List<PurchaseInvoiceLine>();
public ICollection<PurchaseInvoicePayment> Payments { get; set; } = new List<PurchaseInvoicePayment>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per consolidated GRN on a <see cref="PurchaseInvoice"/> — a straight
/// consolidation of that GRN's line totals, not a re-itemization (docs: Purchase
/// Invoice consolidates GRNs, it does not re-enter item lines).
/// </summary>
public class PurchaseInvoiceLine
{
public int PurchaseInvoiceLineId { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public decimal Subtotal { get; set; }
public decimal VatTotal { get; set; }
public decimal LineTotal { get; set; }
}
@@ -0,0 +1,32 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A vendor payment made against a Posted purchase invoice's balance (installments
/// allowed — see PurchaseInvoicePaymentService.PayAsync). Posts its own real GL
/// journal entry (Debit Accounts Payable / Credit the selected bank-or-cash account)
/// before being recorded here.
/// </summary>
public class PurchaseInvoicePayment
{
public int PurchaseInvoicePaymentId { get; set; }
public int PurchaseInvoiceId { get; set; }
public PurchaseInvoice? PurchaseInvoice { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
/// <summary>GL's numeric id for the bank/cash account the payment was made from.</summary>
public long GlBankAccountId { get; set; }
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
public string? Reference { get; set; }
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
public string? GlJournalNo { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -28,5 +28,12 @@ public class PurchaseReturn
public DateTime CreatedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
/// Reverses the Inventory/Clearing lines a Grn posts — <see cref="PurchaseReturnLine"/> carries no unit
/// price/VAT of its own, so it can't reverse a VAT-recoverable line the way a real credit note would.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
}
@@ -31,10 +31,14 @@ public class RunStageInput
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
/// consumption figures below it is <b>not</b> necessarily a pack count.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>
@@ -23,10 +23,17 @@ public class RunStageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
/// <summary>
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
/// </summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
@@ -0,0 +1,46 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A cashier's end-of-day close-out. Aggregates every <see cref="SalesSlip"/> the
/// cashier posted on <see cref="BusinessDate"/>, freezes them against this record
/// (<see cref="SalesSlip.DayEndId"/>) so a slip can only ever belong to one day-end,
/// and posts one consolidated GL journal entry for the day's revenue/tax/COGS —
/// the sales-side equivalent of how <see cref="Grn"/> posts per receipt. Unique per
/// (CashierUserId, BusinessDate): closing twice replays the existing record instead
/// of creating a second one (same idempotent-replay pattern as Grn.ConfirmAsync).
/// </summary>
public class SalesDayEnd
{
public int SalesDayEndId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
/// <summary>The calendar date being closed (matched against each slip's <see cref="SalesSlip.SlipDate"/>, UTC).</summary>
public DateOnly BusinessDate { get; set; }
public int SlipCount { get; set; }
public int BundleCount { get; set; }
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>Sum of the FIFO cost consumed for these slips (from <see cref="StockLedger"/>), for the COGS GL lines.</summary>
public decimal CostOfGoodsSold { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this close (null when there was nothing to post).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int ClosedBy { get; set; }
public User? ClosedByUser { get; set; }
public DateTime ClosedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<SalesSlip> Slips { get; set; } = new List<SalesSlip>();
public ICollection<BundleSale> BundleSales { get; set; } = new List<BundleSale>();
}
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -29,6 +34,14 @@ public class SalesInvoice
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Journal number of the revenue-recognition GL journal entry posted when this
/// invoice is Posted (Debit Accounts Receivable / Credit Sales Revenue + Tax, plus COGS —
/// see SalesPostingService.PostInvoiceAsync). Unlike a SalesSlip, an invoice posts its own
/// GL entry immediately rather than waiting for Sales Day End, since invoices aren't a
/// cashier document and don't go through that close-out.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
@@ -38,4 +51,5 @@ public class SalesInvoice
public uint RowVersion { get; set; }
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
public ICollection<SalesInvoicePayment> Payments { get; set; } = new List<SalesInvoicePayment>();
}
@@ -16,8 +16,6 @@ public class SalesInvoiceLine
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -0,0 +1,32 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A customer payment made against a Posted sales invoice's balance (installments
/// allowed — see SalesInvoicePaymentService.PayAsync). Posts its own real GL journal
/// entry (Debit the selected bank-or-cash account / Credit Accounts Receivable) before
/// being recorded here — the mirror image of <see cref="PurchaseInvoicePayment"/>.
/// </summary>
public class SalesInvoicePayment
{
public int SalesInvoicePaymentId { get; set; }
public int SalesInvoiceId { get; set; }
public SalesInvoice? SalesInvoice { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; }
/// <summary>GL's numeric id for the bank/cash account the payment was received into.</summary>
public long GlBankAccountId { get; set; }
/// <summary>Snapshot of the account's display name at payment time (GL account lists have no local FK).</summary>
public string BankAccountName { get; set; } = string.Empty;
public string? Reference { get; set; }
/// <summary>Journal number of the real GL journal entry this payment posted.</summary>
public string? GlJournalNo { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,38 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales return header — a customer returns previously sold goods, generating an
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
/// <see cref="PurchaseReturn"/> with the direction reversed.
/// </summary>
public class SalesReturn
{
public int ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this return (set on create — it auto-posts).
/// Reverses Inventory/COGS only — <see cref="SalesReturnLine"/> carries no unit price, so revenue/tax aren't
/// reversed here (they'd need a per-line price this document doesn't capture).</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM.
/// </summary>
public class SalesReturnLine
{
public int ReturnLineId { get; set; }
public int ReturnId { get; set; }
public SalesReturn? Return { get; set; }
public int? SalesInvoiceLineId { get; set; }
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
@@ -27,6 +27,10 @@ public class SalesSlip
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
/// <summary>Set once this slip's business date is closed by <see cref="SalesDayEnd"/> — a closed slip is frozen (no further edits).</summary>
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
@@ -16,8 +16,6 @@ public class SalesSlipLine
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
@@ -32,8 +32,13 @@ public class StageInput
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
/// source output declares.
/// </summary>
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
public decimal QtyPerBatch { get; set; }
}
@@ -20,8 +20,14 @@ public class StageOutput
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
/// <summary>
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
/// is null and must be null when it is set — a real item's unit is its own base UOM.
/// WIP never touches stock or the ledger, so this is never converted, only shown.
/// </summary>
public int? UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
public decimal QtyPerBatch { get; set; }
}
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
@@ -27,5 +32,10 @@ public class StockAdjustment
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
/// <summary>Journal number of the real GL journal entry posted for this adjustment (set on create — it
/// auto-posts). Increases and decreases post separately (never netted) so gains/losses stay visible.</summary>
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
}
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from src/dest warehouse.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
+4 -2
View File
@@ -1,8 +1,10 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
/// quantity in the system counts — and as the display label on an intermediate production
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Uom
{
@@ -1,22 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class UomConversion
{
public int ConversionId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int FromUomId { get; set; }
public Uom? FromUom { get; set; }
public int ToUomId { get; set; }
public Uom? ToUom { get; set; }
public decimal Factor { get; set; }
}
@@ -9,6 +9,8 @@ public class Warehouse
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public ICollection<Bin> Bins { get; set; } = new List<Bin>();
}
+4 -1
View File
@@ -5,5 +5,8 @@ public enum AuditAction
{
Create,
Update,
Delete
Delete,
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
Login
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Unit of an item's <b>content size</b> — how much a single stocked pack holds
/// (a 500 ml bottle, a 50 kg sack). Stored as a string in the database.
/// <para>
/// This is <b>not</b> a stocking unit. Stock is always counted in packs
/// (<c>Item.BaseUomId</c>); content is a separate, optional attribute used by
/// production to turn "2000 ml of syrup" into a pack count.
/// </para>
/// <para>
/// Only <see cref="Ml"/> and <see cref="G"/> are ever stored as a <i>base</i> content
/// unit. <see cref="L"/> and <see cref="Kg"/> are entry conveniences normalised ×1000
/// on write by <c>ItemContent.Normalize</c>, so nothing downstream has to convert.
/// </para>
/// </summary>
public enum MeasureUnit
{
Ml,
L,
G,
Kg
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum PurchaseInvoiceStatus
{
Draft,
Posted,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// What a stage input's quantity is expressed in (FR-MFG-04). Stored as a string.
/// <para>
/// Deliberately explicit rather than inferred from whether the item happens to have a
/// content size: templates outlive item edits, so an inferred unit would let adding a
/// content size to an existing item silently reinterpret every saved formula — "300"
/// meaning 300 packs would become 300 ml.
/// </para>
/// </summary>
public enum StageQtyUnit
{
/// <summary>A count of the item's base UOM — bottles, packets, pieces.</summary>
Pack,
/// <summary>
/// An amount of the item's content in its base content unit (ml or g), resolved to
/// packs by <c>IItemMeasure</c> at stage start. Requires the item to have a content size.
/// </summary>
Content
}
+17
View File
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Whether an item is sold under warranty (FR-MD-01). Stored as a string, same
/// convention as <see cref="StockNature"/> and <see cref="TrackingMode"/>.
/// </summary>
public enum Warranty
{
NonWarranty,
Warranty
}
/// <summary>Allowed warranty coverage lengths, in months — set once on the item (FR-MD-01).</summary>
public static class WarrantyPeriods
{
public static readonly int[] AllowedMonths = { 3, 6, 12, 18 };
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain.Notifications.Modules;
[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
public sealed class SaleCreateNotificationModule;
[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
public sealed class SalePostedNotificationModule;
[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
public sealed class GrnCreatedNotificationModule;
[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
public sealed class GrnConfirmedNotificationModule;
[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
public sealed class PurchaseOrderCreatedNotificationModule;
[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
public sealed class PurchaseOrderApprovedNotificationModule;
[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
public sealed class PurchaseReturnCreatedNotificationModule;
[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule;
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule;
[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
public sealed class ItemUpdatedNotificationModule;
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Notifications;
public enum NotificationChannel
{
Email,
Sms,
InApp
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Notifications;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class NotificationModuleAttribute : Attribute
{
public string Key { get; }
public string Label { get; }
public string Description { get; }
public int SortOrder { get; }
public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
{
Key = key;
Label = label;
Description = description;
SortOrder = sortOrder;
}
}
@@ -0,0 +1,7 @@
namespace ERPCore.Domain.Notifications;
public sealed record NotificationModuleDefinition(
string Key,
string Label,
string Description,
int SortOrder);
+4
View File
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
public sealed record LoginActivityDto(
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Branch configuration resource — the singleton toggle for whether this deployment
/// requires a branch on Employee/Warehouse records at all.</summary>
public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// <see cref="BranchingEnabled"/> is <see cref="bool"/>? deliberately: <c>[Required]</c> on a
/// non-nullable bool is a no-op, so an omitted flag would otherwise silently bind to
/// <c>false</c>. Nullable makes the requirement actually bind — an omitted flag is a 400.
/// </para>
/// </summary>
public sealed class UpdateBranchConfigRequest
{
[Required] public bool? BranchingEnabled { get; set; }
}
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) - see the entity docs.
/// <see cref="SellingPricesEnabled"/>, <see cref="SerialNumbersEnabled"/>,
/// <see cref="BatchListsEnabled"/> and <see cref="WarrantyPeriodsEnabled"/> are enforced
/// when items are written.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// <c>false</c> and silently switch everything off. Nullable makes the requirement actually
/// bind - an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
[Required] public bool? SellingPricesEnabled { get; set; }
[Required] public bool? SerialNumbersEnabled { get; set; }
[Required] public bool? BatchListsEnabled { get; set; }
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
}
+24 -8
View File
@@ -5,32 +5,41 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
int GrnId, GrnStatus Status, DateTime PostedAt,
int GrnId, GrnStatus Status, DateTime PostedAt, string GlJournalNo,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
/// <summary>A Confirmed, not-yet-invoiced GRN eligible to be consolidated onto a Purchase Invoice.</summary>
public sealed record GrnEligibleForInvoiceDto(
int GrnId, string DocNo, DateTime PostedAt, int WarehouseId,
int LineCount, decimal Subtotal, decimal VatTotal, decimal GrandTotal);
// Requests ----------------------------------------------------------------------
public sealed class BatchInput
@@ -44,7 +53,6 @@ public sealed class CreateGrnLineInput
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
public int? PoLineId { get; set; }
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
@@ -59,6 +67,11 @@ public sealed class CreateGrnLineInput
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise.
/// </summary>
public List<string>? WarrantyNumbers { get; set; }
}
public sealed class CreateGrnRequest
@@ -68,6 +81,9 @@ public sealed class CreateGrnRequest
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
+4 -4
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
int EmploymentTypeId, string? EmploymentTypeName, int BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
@@ -16,7 +16,7 @@ public sealed record EmployeeDetailDto(
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
int DepartmentId, int DesignationId, int EmploymentTypeId, int BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
@@ -45,7 +45,7 @@ public sealed class CreateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
@@ -82,7 +82,7 @@ public sealed class UpdateEmployeeRequest
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
+4 -2
View File
@@ -7,20 +7,22 @@ namespace ERPCore.Dtos.Hrm;
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
int BranchId, string Code, string Name, string? Address, EntityStatus Status,
int BranchId, string Code, string Name, string? Address, string? PhoneNumber, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
// Code is server-generated (BranchService.GenerateCodeAsync) — never accepted from the client.
public sealed class CreateBranchRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
[StringLength(30)] public string? PhoneNumber { get; set; }
}
public sealed record DepartmentDto(
+17 -3
View File
@@ -5,12 +5,16 @@ namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
/// or Size. Carries no values and no item linkage: the chosen values are encoded into the
/// client-generated SKU rather than stored (docs/10 Part C.9).
/// <para>
/// <c>IsMeasurable</c> marks a dimension whose values are content measurements (500 ml, 1 L)
/// rather than plain labels; the builder captures a number + unit per value and writes it to
/// each generated item's content size.
/// </para>
/// </summary>
public sealed record ItemTypeDto(
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
int ItemTypeId, string Name, bool IsMeasurable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
@@ -18,11 +22,21 @@ public sealed record ItemTypeDto(
public sealed class CreateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>Omitted ⇒ false, i.e. plain-text values. See <see cref="ItemTypeDto"/>.</summary>
public bool IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
/// <summary>
/// Nullable on purpose: a plain <c>bool</c> binds an absent property as <c>false</c>, so any
/// client that PUT only a name — as the item-types screen used to — would silently clear the
/// flag on every rename. Omitting this field <b>preserves</b> the stored value.
/// </summary>
public bool? IsMeasurable { get; set; }
}
public sealed class UpdateItemTypeStatusRequest
+41 -27
View File
@@ -9,7 +9,11 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status);
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -17,26 +21,22 @@ public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decim
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// <c>ContentBaseQty</c>/<c>ContentBaseUnit</c> are echoed back so a detail screen can show
/// what the entered size normalised to (1.5 L ⇒ 1500 ml) — they are server-derived and are
/// not accepted on write.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
Warranty Warranty, int? WarrantyPeriodMonths,
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
decimal? ContentQty, MeasureUnit? ContentUnit,
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
@@ -61,9 +61,22 @@ public sealed class CreateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemRequest
@@ -80,9 +93,22 @@ public sealed class UpdateItemRequest
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[EnumDataType(typeof(Warranty))] public Warranty Warranty { get; set; } = Warranty.NonWarranty;
/// <summary>Required (one of <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; ignored otherwise.</summary>
public int? WarrantyPeriodMonths { get; set; }
[StringLength(20)] public string? TaxClass { get; set; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
[Range(0, double.MaxValue)] public decimal? SellingFlatPrice { get; set; }
/// <summary>
/// How much one pack holds. Supply with <see cref="ContentUnit"/> or leave both null
/// for items with no measurable content. The normalised base pair is derived by the
/// server and is deliberately not accepted here.
/// </summary>
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
}
public sealed class UpdateItemStatusRequest
@@ -101,15 +127,3 @@ public sealed class UpdateReorderRequest
{
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
}
public sealed class UomConversionInput
{
[Required] public int FromUom { get; set; }
[Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
public sealed class UpdateUomConversionsRequest
{
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Notifications;
public sealed record NotificationModuleConfigDto(
string ModuleKey,
string Label,
string Description,
bool EmailEnabled,
bool SmsEnabled,
bool InAppEnabled);
public sealed record NotificationConfigDto(
IReadOnlyList<NotificationModuleConfigDto> Modules,
DateTime? UpdatedAt,
int? UpdatedBy);
public sealed class UpdateNotificationModuleRequest
{
[Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? InAppEnabled { get; set; }
}
public sealed class UpdateNotificationConfigRequest
{
[Required, MinLength(0)] public List<UpdateNotificationModuleRequest> Modules { get; set; } = new();
}
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
public sealed record PurchaseInvoiceLineDto(
int PurchaseInvoiceLineId, int GrnId, string GrnDocNo, DateTime GrnPostedAt,
decimal Subtotal, decimal VatTotal, decimal LineTotal);
public sealed record PurchaseInvoiceTotalsDto(
decimal Subtotal, decimal VatTotal, decimal GrandTotal, decimal PaidAmount, decimal BalanceAmount);
public sealed record PurchaseInvoiceDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
PurchaseInvoiceStatus Status, int GrnCount, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
string? GlJournalNo, DateTime? GlPostedAt,
PurchaseInvoiceTotalsDto Totals, IReadOnlyList<PurchaseInvoiceLineDto> Lines);
public sealed record PurchaseInvoiceSummaryDto(
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
int VendorId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record PurchaseInvoicePaymentDto(
int PurchaseInvoicePaymentId, int PurchaseInvoiceId, decimal Amount, DateTime PaymentDate,
long GlBankAccountId, string BankAccountName, string? Reference, string? GlJournalNo, DateTime CreatedAt);
public sealed class CreatePurchaseInvoicePaymentRequest
{
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
}
public sealed class CreatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
public sealed class UpdatePurchaseInvoiceRequest
{
[Required] public int VendorId { get; set; }
[Required, StringLength(50)] public string VendorInvoiceNo { get; set; } = string.Empty;
[Required] public DateTime VendorInvoiceDate { get; set; }
[Required, MinLength(1)] public List<int> GrnIds { get; set; } = new();
}
@@ -6,7 +6,7 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
int PoLineId, int ItemId, int UomId, int WarehouseId,
int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -25,7 +25,6 @@ public sealed record PurchaseOrderSummaryDto(
public sealed class CreatePoLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
@@ -9,12 +9,13 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
+49 -5
View File
@@ -29,17 +29,23 @@ public sealed record RunSummaryDto(
/// </summary>
public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
/// <summary>
/// One input of a run stage. <c>PlannedQty</c> is expressed in <c>QtyUnit</c> — content
/// (ml/g) or packs — while every consumption figure is always a pack count, so the two are
/// not directly comparable for a Content input.
/// </summary>
public sealed record RunStageInputDto(
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, StageQtyUnit QtyUnit,
decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced − scrapped −
/// transferred (FR-MFG-12) — and never stored.
/// transferred (FR-MFG-12) — and never stored. <c>UomId</c> is the WIP label and is null on
/// the terminal output, whose unit is the finished item's base UOM.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
int RunOutputId, int? ItemId, string Name, int? UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
@@ -64,6 +70,35 @@ public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
/// terminal output of a run that has produced something, costed the same way
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
/// one, so run and produced line coincide 1:1.
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
/// </summary>
public sealed record ProducedLineDto(
int RunId, string DocNo, ProductionRunStatus Status,
int TemplateId, string TemplateName,
int WarehouseId, string WarehouseName,
int ItemId, string ItemName,
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
decimal UnitCost, decimal TotalCost,
DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
/// matched, not just the current page (there is no pagination here, matching the Sales report
/// convention of flat, filter-bounded rows).</summary>
public sealed record ProducedSummaryDto(
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
@@ -93,8 +128,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -110,4 +147,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -35,10 +35,11 @@ public sealed record FieldDefDto(
public sealed record StageInputDto(
int InputId, StageInputSource Source, int? ItemId,
int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
int? FromOutputId, string? FromOutputKey, StageQtyUnit QtyUnit, decimal QtyPerBatch);
/// <summary><c>UomId</c> is the WIP label and is null exactly when <c>ItemId</c> is set.</summary>
public sealed record StageOutputDto(
int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
int OutputId, string Key, int? ItemId, string Name, int? UomId, decimal QtyPerBatch);
public sealed record TemplateStageDto(
int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
@@ -134,8 +135,12 @@ public sealed class SaveInputRequest
[StringLength(60)]
public string? FromOutputKey { get; set; }
[Range(1, int.MaxValue)]
public int UomId { get; set; }
/// <summary>
/// What <see cref="QtyPerBatch"/> means. <c>Content</c> (ml/g) is allowed only on a Stock
/// input whose item has a content size; Upstream inputs must be <c>Pack</c>.
/// </summary>
[EnumDataType(typeof(StageQtyUnit))]
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
@@ -153,8 +158,12 @@ public sealed class SaveOutputRequest
[Required, StringLength(150, MinimumLength = 1)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// The WIP display unit. Required when <see cref="ItemId"/> is null; must be null when it
/// is set, because a real item's unit is its own base UOM.
/// </summary>
[Range(1, int.MaxValue)]
public int UomId { get; set; }
public int? UomId { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal QtyPerBatch { get; set; }
+10 -2
View File
@@ -2,5 +2,13 @@ namespace ERPCore.Dtos.Rbac;
/// <summary>Response for `GET /api/v1/auth/me` — the frontend's authoritative source
/// for the current user's role and permitted sidebar nav codes (replaces trusting
/// the stale, client-only `roleId` cached in localStorage).</summary>
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
/// the stale, client-only `roleId` cached in localStorage). <see cref="BranchId"/>/
/// <see cref="BranchName"/> come from the caller's linked <c>Employee</c> record, when
/// one exists — null for a login with no Employee link. <see cref="IsAdmin"/> gates
/// branch pickers client-side only (no server-side branch enforcement exists yet,
/// see docs/BRANCH_ISOLATION_PLAN.md). <see cref="BranchingEnabled"/> mirrors the
/// singleton <c>BranchConfig</c> toggle — when false the frontend hides branch pickers
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled);
+2 -4
View File
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Sales;
public sealed record BundleSaleLineDto(
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int UomId, int WarehouseId,
int BundleSaleLineId, int ItemId, string Description, decimal Qty, int WarehouseId,
decimal UnitPrice, decimal LineTotal, bool IncludeInBundle, bool IsComponent, int? ParentLineId);
public sealed record BundleSaleDto(
@@ -20,7 +20,7 @@ public sealed record BundleSaleSummaryDto(
decimal ComponentSubtotal, decimal BundlePrice, decimal GrandTotal, DateTime CreatedAt);
public sealed record BundleSaleTemplateLineDto(
int BundleSaleTemplateLineId, int ItemId, int UomId, int WarehouseId, decimal Qty,
int BundleSaleTemplateLineId, int ItemId, int WarehouseId, decimal Qty,
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
@@ -42,7 +42,6 @@ public sealed record BundleSalePostingCheckDto(
public sealed class CreateBundleSaleTemplateLineRequest
{
[Required] public int ItemId { get; set; }
[Required] public int UomId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
@@ -52,7 +51,6 @@ public sealed class CreateBundleSaleTemplateLineRequest
public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateCode { get; set; } = string.Empty;
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();

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