Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 791d853c72 | |||
| cea433e320 | |||
| cba2f91689 | |||
| 9fd290e256 | |||
| 20ecfdc0b2 | |||
| dd82f5b5c4 | |||
| ce55d647b5 | |||
| 268b6562dc | |||
| b389c01dfe | |||
| d24072e20a | |||
| 9185d39c39 | |||
| a2790c13bb | |||
| 940583552a | |||
| 049c98298a | |||
| f5e8e0e5b9 | |||
| f35439e364 | |||
| 4c5e9b347a | |||
| fdd104284e | |||
| 72819e5f7b | |||
| edd1011330 | |||
| 3ae14fad72 | |||
| 84d3318f76 | |||
| bfb8e83a82 | |||
| 40c1d6da27 | |||
| 5aa709db17 | |||
| cf964a19a9 | |||
| a40c6bf598 | |||
| f951700b58 | |||
| b3939e0685 | |||
| af6f02023d | |||
| 9d569a1504 | |||
| 5a44702cf3 | |||
| 2a5dee3fef | |||
| 2584ab8715 | |||
| 6b9d191bf8 | |||
| 283fa21e87 | |||
| 97d19d83ab | |||
| 01d5159d27 | |||
| 80899ed88c | |||
| cfe2466fec | |||
| 9a124c8c63 | |||
| 42ab7e5964 | |||
| e7735135ca | |||
| f61d1f95b8 | |||
| f632c5fc99 | |||
| 70179e1abc | |||
| 8c3d62223c | |||
| e1f19fe413 | |||
| cb6b92d221 | |||
| 3f10ae3bca | |||
| 7274b89b28 | |||
| 61fe1a0e0c | |||
| 941f20d4c5 | |||
| b06d20e649 | |||
| 445d323bed | |||
| 9028255800 | |||
| 1b2f244853 | |||
| 475bcfec1a | |||
| 85db26ec82 | |||
| baee987d26 | |||
| f45a59f20d | |||
| 32304cbaff | |||
| 2b64bb0ebc | |||
| ffe5251236 | |||
| d04a7fb3bc | |||
| d76595085e | |||
| bad33a8492 | |||
| b0216fca30 | |||
| 49c112d188 | |||
| c836b57d54 | |||
| 3a583f80e5 | |||
| 83e0335504 | |||
| 513343b62a | |||
| 256e39ccff | |||
| efb37fce7a | |||
| 01fc2582b6 | |||
| 39a2dc60be | |||
| 821e96e011 |
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
+56
-473
@@ -1,509 +1,92 @@
|
||||
# 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
|
||||
name: CI/CD - ERP Core Monorepo
|
||||
|
||||
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'
|
||||
build-and-deploy-backend:
|
||||
name: Build & Deploy Backend
|
||||
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: ""
|
||||
|
||||
if: github.event.pull_request.merged == true
|
||||
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 ──────────────────────────────────────────────────────────────────────────────
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Restore
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64
|
||||
|
||||
run: dotnet restore Backend/ERPCore.sln
|
||||
- name: Publish
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
run: |
|
||||
rm -rf "$BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
rm -rf /tmp/erpcore-deploy
|
||||
mkdir -p /tmp/erpcore-deploy
|
||||
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")"
|
||||
|
||||
-o /tmp/erpcore-deploy
|
||||
- name: Stop service
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
run: systemctl stop "$SERVICE" || true
|
||||
|
||||
run: systemctl stop erpcore.service
|
||||
- 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"/
|
||||
|
||||
mkdir -p /home/erp/core/publish
|
||||
rm -rf /home/erp/core/publish/*
|
||||
cp -r /tmp/erpcore-deploy/* /home/erp/core/publish/
|
||||
- name: Fix ownership and permissions
|
||||
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 }}
|
||||
chown -R erpuser:erpuser /home/erp/core/publish
|
||||
find /home/erp/core/publish -type d -exec chmod 755 {} +
|
||||
find /home/erp/core/publish -type f -exec chmod 644 {} +
|
||||
chmod +x /home/erp/core/publish/ERPCore
|
||||
- name: Restart service
|
||||
run: systemctl restart erpcore.service
|
||||
- name: Verify
|
||||
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)"
|
||||
systemctl is-active erpcore.service && echo "✅ Service running" || echo "❌ Service failed"
|
||||
|
||||
- name: Start service
|
||||
if: steps.changes.outputs.backend == 'true'
|
||||
build-and-deploy-frontend:
|
||||
name: Build & Deploy Frontend
|
||||
runs-on: linux
|
||||
if: github.event.pull_request.merged == true
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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
|
||||
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--branch production \
|
||||
https://oauth2:${{ secrets.ERPCORE }}@gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git \
|
||||
.
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build --progress=plain --no-cache \
|
||||
-t erpcore-frontend:latest \
|
||||
./Frontend/erp-system
|
||||
- name: Stop & remove existing container
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: |
|
||||
docker stop "$WEB_CONTAINER" 2>/dev/null || true
|
||||
docker rm "$WEB_CONTAINER" 2>/dev/null || true
|
||||
|
||||
docker stop erpcore-frontend 2>/dev/null || true
|
||||
docker rm erpcore-frontend 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 \
|
||||
--name erpcore-frontend \
|
||||
-p 6702:6702 \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-e BACKEND_ORIGIN=http://host.docker.internal:6701 \
|
||||
--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
|
||||
|
||||
--memory="512m" \
|
||||
--cpus="1.0" \
|
||||
--cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
erpcore-frontend:latest
|
||||
- name: Remove old images
|
||||
if: steps.changes.outputs.frontend == 'true'
|
||||
run: docker image prune -f
|
||||
- name: Verify
|
||||
run: |
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter until=168h
|
||||
docker ps --filter name=erpcore-frontend --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" \
|
||||
| grep erpcore-frontend && echo "✅ Container running" || echo "❌ Container failed"
|
||||
|
||||
@@ -36,8 +36,4 @@ Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
# ── Python (Backend/smoke) ────────────────────────────────────────────
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
|
||||
@@ -5,8 +5,6 @@ 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
|
||||
@@ -29,18 +27,6 @@ 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/branch-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class BranchConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IBranchConfigService _config;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Brands;
|
||||
using ERPCore.Dtos.Common;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||
[Route("api/v1/brands")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class BrandsController : ApiControllerBase
|
||||
{
|
||||
private readonly IBrandService _brands;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/bundle-sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class BundleSalesController : ApiControllerBase
|
||||
{
|
||||
private readonly IBundleSaleService _bundles;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Dtos.Common;
|
||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
||||
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||
/// </summary>
|
||||
[Route("api/v1/categories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class CategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Customers;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
|
||||
[Route("api/v1/customers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class CustomersController : ApiControllerBase
|
||||
{
|
||||
private readonly ICustomerService _customers;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Dashboard;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -6,6 +8,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
|
||||
[Route("api/v1/dashboard")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "dashboard")]
|
||||
public sealed class DashboardController : ApiControllerBase
|
||||
{
|
||||
private readonly IDashboardService _dashboard;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -12,6 +14,7 @@ namespace ERPCore.Controllers;
|
||||
/// the existing sales-slip CRUD surface under a more business-friendly route.
|
||||
/// </summary>
|
||||
[Route("api/v1/free-issues")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class FreeIssuesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -14,6 +16,7 @@ namespace ERPCore.Controllers;
|
||||
/// only and is never exposed to the frontend.
|
||||
/// </summary>
|
||||
[Route("api/v1/gl")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class GeneralLedgerController : ApiControllerBase
|
||||
{
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||
[Route("api/v1/grns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "receiving")]
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
|
||||
@@ -94,13 +94,13 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
|
||||
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||
[HttpDelete("{employeeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
|
||||
public async Task<ActionResult<EmployeeDeleteResultDto>> Delete(int employeeId, [FromQuery] bool deleteUserAccount, CancellationToken ct)
|
||||
{
|
||||
await _employees.DeleteAsync(employeeId, ct);
|
||||
return NoContent();
|
||||
var result = await _employees.DeleteAsync(employeeId, deleteUserAccount, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("{employeeId:int}/link-user")]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item photos. Same access as the item master itself.</summary>
|
||||
[Route("api/v1/items/{itemId:int}/images")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemImagesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemImageService _images;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ItemImagesController(IItemImageService images, ICurrentUser currentUser)
|
||||
{
|
||||
_images = images;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ItemImageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<ItemImageDto>>> List(int itemId, CancellationToken ct)
|
||||
=> Ok(await _images.ListAsync(itemId, ct));
|
||||
|
||||
/// <summary>Upload one image (multipart <c>file</c>). 422 CONFIG_DISABLED while item images are off.</summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(ItemImageService.MaxSizeBytes + 64 * 1024)]
|
||||
[ProducesResponseType(typeof(ItemImageDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ItemImageDto>> Upload(int itemId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _images.UploadAsync(itemId, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||
return Created($"/api/v1/items/{itemId}/images/{result.ItemImageId}", result);
|
||||
}
|
||||
|
||||
/// <summary>The image bytes, served inline so an <img> can point straight at it.</summary>
|
||||
[HttpGet("{itemImageId:int}/content")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Content(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
var (content, contentType) = await _images.OpenAsync(itemId, itemImageId, ct);
|
||||
// Stored files are immutable (new upload ⇒ new id), so the browser may cache them.
|
||||
Response.Headers.CacheControl = "private, max-age=86400";
|
||||
return File(content, contentType);
|
||||
}
|
||||
|
||||
[HttpDelete("{itemImageId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Delete(int itemId, int itemImageId, CancellationToken ct)
|
||||
{
|
||||
await _images.DeleteAsync(itemId, itemImageId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.ItemTypes;
|
||||
@@ -13,6 +15,7 @@ namespace ERPCore.Controllers;
|
||||
/// into the client-generated SKU (docs/10 Part C.9).
|
||||
/// </summary>
|
||||
[Route("api/v1/item-types")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemTypesController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemTypeService _itemTypes;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
||||
[Route("api/v1/items")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ItemsController : ApiControllerBase
|
||||
{
|
||||
private readonly IItemService _items;
|
||||
@@ -27,6 +30,33 @@ public sealed class ItemsController : ApiControllerBase
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||
|
||||
/// <summary>Items list enriched with stock value, low-stock, last-purchase and
|
||||
/// trailing-30-day sales aggregates, for the Items overview table.</summary>
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ItemOverviewDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ItemOverviewDto>>> Overview(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] int? categoryId,
|
||||
[FromQuery] int? subCategoryId,
|
||||
[FromQuery] int? brandId,
|
||||
[FromQuery] TrackingMode? trackingMode,
|
||||
[FromQuery] StockNature? stockNature,
|
||||
[FromQuery] ItemStockFilter? stock,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.GetOverviewAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, stockNature, stock, ct));
|
||||
|
||||
/// <summary>Barcode/RFID lookup — matches an item's barcode/SKU, or a batch/serial
|
||||
/// barcode/no (optionally scoped to <paramref name="itemId"/> first).</summary>
|
||||
[HttpGet("scan")]
|
||||
[ProducesResponseType(typeof(ItemScanResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemScanResultDto>> Scan([FromQuery] string code, [FromQuery] int? itemId, CancellationToken ct)
|
||||
{
|
||||
var result = await _items.ScanAsync(code, itemId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||
[HttpGet("{itemId:int}")]
|
||||
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// Data only — no posting in Phase 1.
|
||||
/// </summary>
|
||||
[Route("api/v1/journal-entries")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "ledgers")]
|
||||
public sealed class JournalEntriesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/notification-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class NotificationConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly INotificationConfigService _config;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/notifications")]
|
||||
public sealed class NotificationsController : ApiControllerBase
|
||||
{
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public NotificationsController(INotificationService notifications, ICurrentUser currentUser)
|
||||
{
|
||||
_notifications = notifications;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> Get([FromQuery] int take = 20, CancellationToken ct = default)
|
||||
{
|
||||
var feed = await _notifications.GetFeedAsync(_currentUser.AuditUserId, take, ct);
|
||||
return Ok(feed);
|
||||
}
|
||||
|
||||
[HttpPost("mark-all-read")]
|
||||
public async Task<ActionResult> MarkAllRead(CancellationToken ct)
|
||||
{
|
||||
await _notifications.MarkAllReadAsync(_currentUser.AuditUserId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,6 +18,7 @@ namespace ERPCore.Controllers;
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/product-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class ProductConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductConfigService _config;
|
||||
@@ -42,5 +45,4 @@ public sealed class ProductConfigController : ApiControllerBase
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).</summary>
|
||||
[Route("api/v1/production-runs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionRunsController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionRunService _runs;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Production;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
|
||||
[Route("api/v1/production-templates")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "production")]
|
||||
public sealed class ProductionTemplatesController : ApiControllerBase
|
||||
{
|
||||
private readonly IProductionTemplateService _templates;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Grn;
|
||||
@@ -9,6 +11,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase invoice endpoints — vendor invoices consolidating Confirmed GRNs.</summary>
|
||||
[Route("api/v1/purchase-invoices")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseInvoiceService _invoices;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
||||
[Route("api/v1/purchase-orders")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseOrderService _pos;
|
||||
@@ -32,7 +35,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
|
||||
/// <summary>Create a PO — non-draft is issued straight into PendingApproval (FR-PROC-04). Totals computed server-side.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
@@ -57,7 +60,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
/// <summary>Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||
[HttpPost("{poId:int}/submit")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -67,6 +70,7 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
|
||||
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||
[HttpDelete("{poId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -76,13 +80,28 @@ public sealed class PurchaseOrdersController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
||||
/// <summary>Approve — PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller
|
||||
/// isn't an Admin or is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise.</summary>
|
||||
[HttpPost("{poId:int}/approve")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
|
||||
=> Ok(await _pos.ApproveAsync(poId, ct));
|
||||
|
||||
/// <summary>Reject — PendingApproval → Draft, so the requester can revise and resubmit.
|
||||
/// Same caller restrictions as <see cref="Approve"/>.</summary>
|
||||
[HttpPost("{poId:int}/reject")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Reject(int poId, [FromBody] RejectPurchaseOrderRequest request, CancellationToken ct)
|
||||
=> Ok(await _pos.RejectAsync(poId, request.Reason, ct));
|
||||
|
||||
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
|
||||
[HttpPost("{poId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||
[Route("api/v1/purchase-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseReturnService _returns;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
||||
[Route("api/v1/requisitions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RequisitionsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRequisitionService _requisitions;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
||||
[Route("api/v1/rfqs")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
|
||||
public sealed class RfqsController : ApiControllerBase
|
||||
{
|
||||
private readonly IRfqService _rfqs;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Rbac;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||
[Route("api/v1/roles")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class RolesController : ApiControllerBase
|
||||
{
|
||||
private readonly IRoleService _roles;
|
||||
@@ -64,6 +67,7 @@ public sealed class RolesController : ApiControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{roleId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration endpoints — the singleton "allow cash customer" toggle.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> gated on the Sales nav policy (the New Sale form has to read it), same
|
||||
/// Phase-1 posture as <see cref="BranchConfigController"/> — any Sales user can flip the flag
|
||||
/// until per-endpoint RBAC lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/sales-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesConfigService _config;
|
||||
|
||||
public SalesConfigController(ISalesConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(SalesConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SalesConfigDto>> Update(
|
||||
[FromBody] UpdateSalesConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
/// <summary>Cashier day-end close-out — locks a cashier's Posted sales slips for a business
|
||||
/// date and posts one consolidated GL journal entry for the day (docs/14 Sales API).</summary>
|
||||
[Route("api/v1/sales-day-end")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesDayEndController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesDayEndService _dayEnds;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,15 +9,65 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-invoices")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesInvoicesController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesInvoiceService _invoices;
|
||||
private readonly ISalesInvoicePaymentService _payments;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
private readonly ISalesDomainService _sales;
|
||||
private readonly IStockService _stock;
|
||||
|
||||
public SalesInvoicesController(ISalesInvoiceService invoices, ISalesInvoicePaymentService payments)
|
||||
public SalesInvoicesController(
|
||||
ISalesInvoiceService invoices, ISalesInvoicePaymentService payments, ISalesPricingService pricing,
|
||||
ISalesDomainService sales, IStockService stock)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_pricing = pricing;
|
||||
_sales = sales;
|
||||
_stock = stock;
|
||||
}
|
||||
|
||||
// Lets the New Sale form warn, per line, when qty exceeds the stock a sale can issue — under
|
||||
// the "sales" policy, since sales users may not hold the "stock" nav permission. Non-stocked
|
||||
// items (services) are never short, so they report IsStocked = false and no limit.
|
||||
[HttpGet("stock-availability")]
|
||||
[ProducesResponseType(typeof(SalesStockAvailabilityDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesStockAvailabilityDto>> StockAvailability(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
bool isStocked;
|
||||
try
|
||||
{
|
||||
isStocked = await _sales.IsStockedItemAsync(itemId, ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var available = isStocked ? (await _stock.GetOnHandAsync(itemId, warehouseId, ct)).Available : 0m;
|
||||
return Ok(new SalesStockAvailabilityDto(itemId, warehouseId, isStocked, available));
|
||||
}
|
||||
|
||||
// Lets the New Sale form pre-fill Unit price for items with no flat selling price, using the
|
||||
// same resolution the server applies on save.
|
||||
[HttpGet("suggested-price")]
|
||||
[ProducesResponseType(typeof(SalesSuggestedPriceDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesSuggestedPriceDto>> SuggestedPrice(
|
||||
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var price = await _pricing.ResolveAsync(itemId, warehouseId, null, false, ct);
|
||||
return Ok(new SalesSuggestedPriceDto(itemId, warehouseId, price.UnitPrice, price.PriceSource));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -26,8 +78,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/reports/sales")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesReportsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReportService _reports;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||
[Route("api/v1/sales-returns")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReturnService _returns;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -5,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipPromotionsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesPromotionSuggestionService _suggestions;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
@@ -7,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/sales-slips")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "sales")]
|
||||
public sealed class SalesSlipsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesSlipService _slips;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Dtos.Search;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/search")]
|
||||
public sealed class SearchController : ApiControllerBase
|
||||
{
|
||||
private readonly IGlobalSearchService _search;
|
||||
|
||||
public SearchController(IGlobalSearchService search) => _search = search;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<GlobalSearchResultDto>>> Get([FromQuery] string q, CancellationToken ct)
|
||||
{
|
||||
var results = await _search.SearchAsync(q, 6, ct);
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAdjustmentService _adjustments;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||
[Route("api/v1/stock")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||
[Route("api/v1/stock-counts")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockCountsController : ApiControllerBase
|
||||
{
|
||||
private readonly ICountService _counts;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||
[Route("api/v1/stock-transfers")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "stock")]
|
||||
public sealed class StockTransfersController : ApiControllerBase
|
||||
{
|
||||
private readonly ITransferService _transfers;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Categories;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
|
||||
/// since a subcategory only exists in the context of one.
|
||||
/// </summary>
|
||||
[Route("api/v1/subcategories")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class SubCategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using ERPCore.Dtos.Support;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
[Route("api/v1/support-tickets")]
|
||||
public sealed class SupportTicketsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISupportTicketService _tickets;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public SupportTicketsController(ISupportTicketService tickets, ICurrentUser currentUser)
|
||||
{
|
||||
_tickets = tickets;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<SupportTicketDto>> Create([FromBody] CreateSupportTicketRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _tickets.CreateAsync(_currentUser.AuditUserId, request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Value.TicketId }, result.Value);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SupportTicketSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SupportTicketSummaryDto>>> GetList(CancellationToken ct)
|
||||
{
|
||||
var list = await _tickets.GetListAsync(_currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SupportTicketDto>> GetById(int id, CancellationToken ct)
|
||||
{
|
||||
var result = await _tickets.GetByIdAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/messages")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SupportTicketDto>> AddMessage(int id, [FromBody] AddSupportTicketMessageRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _tickets.AddMessageAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}/status")]
|
||||
[ProducesResponseType(typeof(SupportTicketDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<SupportTicketDto>> UpdateStatus(int id, [FromBody] UpdateSupportTicketStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _tickets.UpdateStatusAsync(id, _currentUser.AuditUserId, _currentUser.IsAdmin, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.TaxRates;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Tax rate master endpoints — see <see cref="Domain.Entities.TaxRate"/>.</summary>
|
||||
[Route("api/v1/tax-rates")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class TaxRatesController : ApiControllerBase
|
||||
{
|
||||
private readonly ITaxRateService _taxRates;
|
||||
|
||||
public TaxRatesController(ITaxRateService taxRates) => _taxRates = taxRates;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<TaxRateDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<TaxRateDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _taxRates.ListAsync(query, status, ct));
|
||||
|
||||
[HttpGet("{taxRateId:int}")]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TaxRateDto>> GetById(int taxRateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _taxRates.GetAsync(taxRateId, ct);
|
||||
if (result is null) return NotFound();
|
||||
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<TaxRateDto>> Create([FromBody] CreateTaxRateRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _taxRates.CreateAsync(request, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Created($"/api/v1/tax-rates/{result.Value.TaxRateId}", result.Value);
|
||||
}
|
||||
|
||||
[HttpPut("{taxRateId:int}")]
|
||||
[ProducesResponseType(typeof(TaxRateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
public async Task<ActionResult<TaxRateDto>> Update(int taxRateId, [FromBody] UpdateTaxRateRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _taxRates.UpdateAsync(taxRateId, request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{taxRateId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int taxRateId, [FromBody] UpdateTaxRateStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _taxRates.SetStatusAsync(taxRateId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
[Route("api/v1/uoms")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class UomsController : ApiControllerBase
|
||||
{
|
||||
private readonly IUomService _uoms;
|
||||
@@ -15,8 +19,9 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -26,4 +31,14 @@ public sealed class UomsController : ApiControllerBase
|
||||
var dto = await _uoms.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{uomId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
@@ -14,6 +17,7 @@ namespace ERPCore.Controllers;
|
||||
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||
/// </summary>
|
||||
[Route("api/v1/users")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
@@ -83,4 +87,20 @@ public sealed class UsersController : ApiControllerBase
|
||||
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only escape hatch: permanently deletes a stale AuthHex identity (by email/mobile/NIC/
|
||||
/// username) that is blocking new registrations. Does not touch ERPCore's own `users` table.
|
||||
/// See <see cref="IUserManagementService.PurgeAuthHexIdentityAsync"/>.
|
||||
/// </summary>
|
||||
[HttpPost("purge-authhex-identity")]
|
||||
[ProducesResponseType(typeof(DeleteAuthHexUserResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult<DeleteAuthHexUserResult>> PurgeAuthHexIdentity([FromBody] PurgeAuthHexIdentityRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!_currentUser.IsAdmin)
|
||||
throw new DomainException("FORBIDDEN", "Only an administrator can purge an AuthHex identity.", 403);
|
||||
|
||||
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Vendors;
|
||||
@@ -8,6 +10,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
[Route("api/v1/vendors")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
|
||||
public sealed class VendorsController : ApiControllerBase
|
||||
{
|
||||
private readonly IVendorService _vendors;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Warehouses;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -7,6 +9,7 @@ namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||
[Route("api/v1/warehouses")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "warehouses")]
|
||||
public sealed class WarehousesController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarehouseService _warehouses;
|
||||
@@ -37,6 +40,24 @@ public sealed class WarehousesController : ApiControllerBase
|
||||
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
|
||||
}
|
||||
|
||||
[HttpPut("{warehouseId:int}")]
|
||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<WarehouseDto>> Update(int warehouseId, [FromBody] UpdateWarehouseRequest request, CancellationToken ct)
|
||||
=> Ok(await _warehouses.UpdateAsync(warehouseId, request, ct));
|
||||
|
||||
[HttpDelete("{warehouseId:int}")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "warehouses" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int warehouseId, CancellationToken ct)
|
||||
{
|
||||
await _warehouses.DeleteAsync(warehouseId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{warehouseId:int}/bins")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Custom warranty period master — managed under Products like Item Variants.</summary>
|
||||
[Route("api/v1/warranty-periods")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "products")]
|
||||
public sealed class WarrantyPeriodsController : ApiControllerBase
|
||||
{
|
||||
private readonly IWarrantyPeriodService _periods;
|
||||
|
||||
public WarrantyPeriodsController(IWarrantyPeriodService periods) => _periods = periods;
|
||||
|
||||
/// <summary>Custom periods only (defaults are fixed); filter <c>status=Active</c> for selectable rows.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<WarrantyPeriodDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<List<WarrantyPeriodDto>>> List([FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _periods.ListAsync(status, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(WarrantyPeriodDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<WarrantyPeriodDto>> Create([FromBody] CreateWarrantyPeriodRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _periods.CreateAsync(request.Months!.Value, ct);
|
||||
return Created($"/api/v1/warranty-periods/{result.WarrantyPeriodId}", result);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{warrantyPeriodId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int warrantyPeriodId, [FromBody] UpdateWarrantyPeriodStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _periods.SetStatusAsync(warrantyPeriodId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,7 @@ public class Batch
|
||||
|
||||
public string BatchNo { get; set; } = string.Empty;
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||
public string? Barcode { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ public class BranchConfig
|
||||
|
||||
public bool BranchingEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>Independent sub-toggle: when the global <see cref="BranchingEnabled"/> is on
|
||||
/// but this is off, Warehouses stop asking for/showing a branch while Employees etc.
|
||||
/// still do. Meaningless when <see cref="BranchingEnabled"/> is off (everything is
|
||||
/// single-branch already).</summary>
|
||||
public bool WarehouseBranchingEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -29,6 +29,9 @@ public class Customer
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
@@ -14,6 +14,9 @@ public class Item
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||
public string? Barcode { get; set; }
|
||||
|
||||
public int CategoryId { get; set; }
|
||||
public Category? Category { get; set; }
|
||||
|
||||
@@ -41,7 +44,14 @@ public class Item
|
||||
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>
|
||||
/// The tax rate a sales/purchase line for this item defaults to (see
|
||||
/// <see cref="TaxRate"/>) — replaces the old free-text "tax class" label that nothing
|
||||
/// ever read. Null means no default; a line must be given an explicit tax percentage.
|
||||
/// </summary>
|
||||
public int? TaxRateId { get; set; }
|
||||
public TaxRate? TaxRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional selling price range and flat price used by Sales only.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A product photo attached to an item. Like <see cref="EmployeeDocument"/>, the file lives
|
||||
/// behind <see cref="Infra.Storage.IFileStorageService"/> under a server-generated name and is
|
||||
/// only reachable through the authenticated items API — never a static path. Only uploadable
|
||||
/// while <see cref="ProductConfig.ItemImagesEnabled"/> is on; existing images stay readable
|
||||
/// after the flag is switched off (reads are never gated).
|
||||
/// </summary>
|
||||
public class ItemImage
|
||||
{
|
||||
public int ItemImageId { get; set; }
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
public string StoredFileName { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Display order; the lowest is the item's main image.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public int UploadedBy { get; set; }
|
||||
public DateTime UploadedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A single raised notification event (e.g. "GRN Confirmed"). Broadcast to
|
||||
/// active users via <see cref="NotificationRecipient"/> rows, gated by
|
||||
/// whether the module's InApp channel is enabled in <see cref="NotificationConfig"/>.
|
||||
/// </summary>
|
||||
public class Notification
|
||||
{
|
||||
public int NotificationId { get; set; }
|
||||
|
||||
public string ModuleKey { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public string? RelatedEntityType { get; set; }
|
||||
public int? RelatedEntityId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public ICollection<NotificationRecipient> Recipients { get; set; } = new List<NotificationRecipient>();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-user read state for a broadcast <see cref="Notification"/>.
|
||||
/// </summary>
|
||||
public class NotificationRecipient
|
||||
{
|
||||
public int NotificationRecipientId { get; set; }
|
||||
|
||||
public int NotificationId { get; set; }
|
||||
public Notification? Notification { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
@@ -10,6 +10,10 @@ public class NumberSequence
|
||||
{
|
||||
public int SequenceId { get; set; }
|
||||
public string DocType { get; set; } = string.Empty;
|
||||
/// <summary>Per-branch counter key (docs/BRANCH_ISOLATION_PLAN.md §5) — backfilled to the
|
||||
/// deployment's default branch for rows that predate per-branch numbering.</summary>
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
public int Year { get; set; }
|
||||
public int LastNumber { get; set; }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,26 @@ public class ProductConfig
|
||||
public bool BatchListsEnabled { get; set; } = true;
|
||||
public bool WarrantyPeriodsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Lets the item create screen add a new item variant (item type) on the spot, instead
|
||||
/// of only picking from the Item Variants master. Advisory like <see cref="ItemTypesEnabled"/>
|
||||
/// - it gates a UI shortcut; the Item Variants master itself stays available either way.
|
||||
/// </summary>
|
||||
public bool CustomVariantsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows photos to be uploaded to items (<see cref="ItemImage"/>). Enforced on upload
|
||||
/// (<c>CONFIG_DISABLED</c>); images added earlier stay viewable when switched off.
|
||||
/// </summary>
|
||||
public bool ItemImagesEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lets items use the active <see cref="WarrantyPeriod"/> master rows alongside the default
|
||||
/// <see cref="Enums.WarrantyPeriods.AllowedMonths"/>. Enforced on item writes. The periods
|
||||
/// themselves are managed under Products, not here.
|
||||
/// </summary>
|
||||
public bool CustomWarrantyPeriodsEnabled { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
|
||||
@@ -20,6 +20,9 @@ public class PurchaseInvoice
|
||||
public int VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public string VendorSnapshotName { get; set; } = string.Empty;
|
||||
public string? VendorSnapshotTaxNo { get; set; }
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace ERPCore.Domain.Entities;
|
||||
/// <summary>
|
||||
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
|
||||
/// <see cref="RowVersion"/> ETag token; editable while open (FR-PROC-05).
|
||||
/// Phase 1 auto-approves on creation; <see cref="ApprovalRequired"/> is retained
|
||||
/// for the future approval workflow. Totals are computed server-side from lines
|
||||
/// (not stored). Model: docs/10 Part C.2.
|
||||
/// A submitted/issued PO goes to <see cref="PurchaseOrderStatus.PendingApproval"/> and
|
||||
/// needs a separate Approve action (an Admin who isn't <see cref="CreatedBy"/>) before it
|
||||
/// reaches <see cref="PurchaseOrderStatus.Approved"/>. Totals are computed server-side
|
||||
/// from lines (not stored). Model: docs/10 Part C.2.
|
||||
/// </summary>
|
||||
public class PurchaseOrder
|
||||
{
|
||||
@@ -17,15 +18,25 @@ public class PurchaseOrder
|
||||
public int VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public int? RequisitionId { get; set; }
|
||||
public Requisition? Requisition { get; set; }
|
||||
|
||||
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
|
||||
/// <summary>True once the PO has been (or needs to be) routed through approval —
|
||||
/// i.e. it is not a still-editable Draft.</summary>
|
||||
public bool ApprovalRequired { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
/// <summary>Who approved this PO — set only when <see cref="Status"/> reaches Approved.</summary>
|
||||
public int? ApprovedBy { get; set; }
|
||||
public User? Approver { get; set; }
|
||||
public DateTime? ApprovedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ public class Requisition
|
||||
public int RequestedBy { get; set; }
|
||||
public User? Requester { get; set; }
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ public class Rfq
|
||||
public int RequisitionId { get; set; }
|
||||
public Requisition? Requisition { get; set; }
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public RfqStatus Status { get; set; } = RfqStatus.Open;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
||||
@@ -6,6 +8,13 @@ public class RolePermission
|
||||
public int RoleId { get; set; }
|
||||
public int PermissionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which mutating actions the role may perform within this permission's module
|
||||
/// (see <see cref="RolePermissionAction"/>); defaults to <see cref="RolePermissionAction.All"/>
|
||||
/// so existing grants keep today's behavior until an admin narrows them.
|
||||
/// </summary>
|
||||
public RolePermissionAction Actions { get; set; } = RolePermissionAction.All;
|
||||
|
||||
public Role? Role { get; set; }
|
||||
public Permission? Permission { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales configuration — a singleton row (single-tenant, docs/00-CORE §1), same shape as
|
||||
/// <see cref="BranchConfig"/>. When <see cref="AllowCashCustomer"/> is on, sales documents may
|
||||
/// be raised against the generic "Cash Customer" (<see cref="CashCustomerId"/>) and the New Sale
|
||||
/// form defaults to it; when off, a real customer must be picked and the cash customer is
|
||||
/// rejected server-side (see SalesDomainService.ValidateSalesHeaderAsync).
|
||||
/// </summary>
|
||||
public class SalesConfig
|
||||
{
|
||||
/// <summary>Always 1 - the singleton row's id.</summary>
|
||||
public const int SingletonId = 1;
|
||||
|
||||
public int ConfigId { get; set; }
|
||||
|
||||
public bool AllowCashCustomer { get; set; }
|
||||
|
||||
/// <summary>The Customer row used for cash (walk-in, unnamed) sales. Created on first
|
||||
/// enabling <see cref="AllowCashCustomer"/> and kept afterwards, so switching the option
|
||||
/// off and on again reuses the same customer (and its sales history).</summary>
|
||||
public int? CashCustomerId { get; set; }
|
||||
public Customer? CashCustomer { get; set; }
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a free quantity (the New Sale "Free"
|
||||
/// column); when off, the column is hidden and a free qty above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowFreeItems { get; set; } = true;
|
||||
|
||||
/// <summary>When on, sales invoice lines may carry a line discount (the New Sale "Disc %"
|
||||
/// column); when off, the column is hidden and a discount above zero is rejected server-side
|
||||
/// (see SalesInvoiceService.BuildLinesAsync). Defaults on, matching the behaviour before
|
||||
/// this option existed.</summary>
|
||||
public bool AllowDiscounts { get; set; } = true;
|
||||
|
||||
/// <summary>When on, posting a sale (invoice, slip, bundle) may issue more than the stock on
|
||||
/// hand — the shortfall drives on-hand negative (see FifoCostingService.ConsumeAsync) and the
|
||||
/// pre-post check reports it as a warning only. When off (default), posting is blocked with
|
||||
/// STOCK_NEGATIVE_BLOCKED until stock covers every line.</summary>
|
||||
public bool AllowOverStockSale { get; set; }
|
||||
|
||||
/// <summary>When on (default) and no <see cref="DefaultWarehouseId"/> is saved, New Sale shows
|
||||
/// a Warehouse picker. When off, a default must be saved.</summary>
|
||||
public bool AllowWarehouseSelection { get; set; } = true;
|
||||
|
||||
/// <summary>Default sales warehouse. Whenever set, New Sale shows only this warehouse (locked)
|
||||
/// and every sales invoice (header and lines) is forced onto it server-side (see
|
||||
/// SalesDomainService.GetForcedSalesWarehouseIdAsync). Required while
|
||||
/// <see cref="AllowWarehouseSelection"/> is off; clearing it brings the picker back.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public Warehouse? DefaultWarehouse { get; set; }
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? UpdatedBy { get; set; }
|
||||
public User? UpdatedByUser { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -27,6 +27,16 @@ public class SalesDayEnd
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sum of <see cref="SalesSlip.PaidAmount"/> across the day's slips (bundle sales have no
|
||||
/// partial-payment field and are always counted as fully paid) — what actually gets debited
|
||||
/// to Cash on close. <see cref="BalanceTotal"/> is the rest (<c>GrandTotal - PaidTotal</c>),
|
||||
/// debited to Accounts Receivable instead. Before partial payments existed these were always
|
||||
/// equal to GrandTotal/0 respectively (every sale was assumed fully cash).
|
||||
/// </summary>
|
||||
public decimal PaidTotal { get; set; }
|
||||
public decimal BalanceTotal { 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; }
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ public class SalesSlipLine
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public int? SerialId { get; set; }
|
||||
public Serial? Serial { get; set; }
|
||||
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal BaseCost { get; set; }
|
||||
public string PriceSource { get; set; } = string.Empty;
|
||||
|
||||
@@ -13,4 +13,7 @@ public class Serial
|
||||
|
||||
public string SerialNo { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "InStock";
|
||||
|
||||
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||
public string? Barcode { get; set; }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ public class StockCountLine
|
||||
|
||||
public int? BinId { get; set; }
|
||||
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public int? SerialId { get; set; }
|
||||
public Serial? Serial { get; set; }
|
||||
|
||||
public decimal SystemQty { get; set; }
|
||||
public decimal? CountedQty { get; set; }
|
||||
public decimal? Variance { get; set; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A help-desk ticket raised by a user from the Help page. Status is
|
||||
/// admin-managed; the conversation is a flat thread of <see cref="SupportTicketMessage"/> rows.
|
||||
/// Field/concurrency shape follows the same conventions as <see cref="Employee"/>
|
||||
/// (CreatedBy/UpdatedBy actor stamps, RowVersion for optimistic concurrency).
|
||||
/// </summary>
|
||||
public class SupportTicket
|
||||
{
|
||||
public int TicketId { get; set; }
|
||||
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public TicketStatus Status { get; set; } = TicketStatus.Open;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public int? UpdatedBy { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<SupportTicketMessage> Messages { get; set; } = new List<SupportTicketMessage>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>One message in a <see cref="SupportTicket"/> thread — from the submitter or an admin.</summary>
|
||||
public class SupportTicketMessage
|
||||
{
|
||||
public int MessageId { get; set; }
|
||||
|
||||
public int TicketId { get; set; }
|
||||
public SupportTicket? Ticket { get; set; }
|
||||
|
||||
public int SenderUserId { get; set; }
|
||||
public bool IsFromAdmin { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g. "Standard" 15%, "Zero-Rated" 0%, "Exempt" 0%) — the real backing
|
||||
/// for <see cref="Item.TaxRateId"/>. Before this existed, <c>Item.TaxClass</c> was a free-text
|
||||
/// label nothing ever read, and every sales/purchase line carried its own hand-typed tax
|
||||
/// percentage with no link back to the item at all. Now a line's tax defaults to whatever
|
||||
/// percentage the item's assigned TaxRate carries at the moment the line is created — change
|
||||
/// the rate here and every new document picks it up; a line already created keeps the percentage
|
||||
/// it was given (SalesSlipLine.TaxPct etc. are snapshots, never recomputed from this table).
|
||||
/// </summary>
|
||||
public class TaxRate
|
||||
{
|
||||
public int TaxRateId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal Percentage { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,4 +12,8 @@ public class Uom
|
||||
{
|
||||
public int UomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ public class User
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const int SystemUserId = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Username prefix of a permanently deleted login whose row is kept only because history
|
||||
/// (audit log, documents) still references it. Such rows are hidden from user management.
|
||||
/// </summary>
|
||||
public const string DeletedUsernamePrefix = "deleted-user-";
|
||||
|
||||
public int UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
@@ -17,6 +17,9 @@ public class Vendor
|
||||
public string Currency { get; set; } = "LKR";
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { 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 string? Address { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Custom warranty period master — a coverage length offered to items alongside the fixed
|
||||
/// defaults in <see cref="WarrantyPeriods.AllowedMonths"/>, while
|
||||
/// <see cref="ProductConfig.CustomWarrantyPeriodsEnabled"/> is on.
|
||||
/// <para>
|
||||
/// Always in months (a "2 year" entry is stored as 24). Like <see cref="ItemType"/> it is
|
||||
/// deliberately unlinked: an item stores the number of months itself, not a reference, so
|
||||
/// deactivating a period only stops it being offered — items already using it keep it.
|
||||
/// The length is immutable (no PUT); a different length is a different row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WarrantyPeriod
|
||||
{
|
||||
public int WarrantyPeriodId { get; set; }
|
||||
public int Months { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on
|
||||
/// creation, so <see cref="PendingApproval"/> is reserved (not entered) until the
|
||||
/// approval workflow is enabled (FR-PROC-04). Stored as a string.
|
||||
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
|
||||
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
|
||||
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back
|
||||
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string.
|
||||
/// </summary>
|
||||
public enum PurchaseOrderStatus
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Fine-grained actions a <see cref="Entities.RolePermission"/> can grant within a module
|
||||
/// it already has nav-level access to (see <see cref="Infra.Auth.NavPolicies"/>). Nav-level
|
||||
/// access answers "can this role reach this module"; this answers "which mutations within
|
||||
/// it can this role actually perform" — e.g. a role that can see Procurement but should not
|
||||
/// be able to delete or approve purchase orders.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum RolePermissionAction
|
||||
{
|
||||
None = 0,
|
||||
View = 1,
|
||||
Create = 2,
|
||||
Edit = 4,
|
||||
Delete = 8,
|
||||
Approve = 16,
|
||||
|
||||
All = View | Create | Edit | Delete | Approve
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum TicketStatus
|
||||
{
|
||||
Open,
|
||||
InProgress,
|
||||
Resolved
|
||||
}
|
||||
@@ -29,6 +29,21 @@ public sealed class AuthSessionResponse
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of AuthHex's admin-only deleteUser function.</summary>
|
||||
public sealed class DeleteAuthHexUserResult
|
||||
{
|
||||
public int DeletedCount { get; set; }
|
||||
public List<DeletedAuthHexUser>? Deleted { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DeletedAuthHexUser
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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);
|
||||
public sealed record BranchConfigDto(bool BranchingEnabled, bool WarehouseBranchingEnabled, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flag. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
@@ -17,4 +17,5 @@ public sealed record BranchConfigDto(bool BranchingEnabled, DateTime? UpdatedAt,
|
||||
public sealed class UpdateBranchConfigRequest
|
||||
{
|
||||
[Required] public bool? BranchingEnabled { get; set; }
|
||||
[Required] public bool? WarehouseBranchingEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace ERPCore.Dtos.Config;
|
||||
public sealed record ProductConfigDto(
|
||||
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
|
||||
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
|
||||
bool CustomVariantsEnabled, bool ItemImagesEnabled,
|
||||
bool CustomWarrantyPeriodsEnabled,
|
||||
DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
@@ -32,4 +34,7 @@ public sealed class UpdateProductConfigRequest
|
||||
[Required] public bool? SerialNumbersEnabled { get; set; }
|
||||
[Required] public bool? BatchListsEnabled { get; set; }
|
||||
[Required] public bool? WarrantyPeriodsEnabled { get; set; }
|
||||
[Required] public bool? CustomVariantsEnabled { get; set; }
|
||||
[Required] public bool? ItemImagesEnabled { get; set; }
|
||||
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>Sales configuration resource. <c>CashCustomerId</c>/<c>CashCustomerName</c> identify
|
||||
/// the generic cash customer (null until the option has been switched on at least once).</summary>
|
||||
public sealed record SalesConfigDto(
|
||||
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
|
||||
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// Nullable bool so an omitted flag is a 400 rather than silently binding to <c>false</c>
|
||||
/// (same reasoning as <see cref="UpdateBranchConfigRequest"/>).
|
||||
/// </summary>
|
||||
public sealed class UpdateSalesConfigRequest
|
||||
{
|
||||
[Required] public bool? AllowCashCustomer { get; set; }
|
||||
[Required] public bool? AllowFreeItems { get; set; }
|
||||
[Required] public bool? AllowDiscounts { get; set; }
|
||||
[Required] public bool? AllowOverStockSale { get; set; }
|
||||
[Required] public bool? AllowWarehouseSelection { get; set; }
|
||||
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public sealed record CustomerDto(
|
||||
int CreditDays,
|
||||
int? DefaultWarehouseId,
|
||||
EntityStatus Status,
|
||||
int BranchId,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt);
|
||||
|
||||
@@ -40,6 +41,7 @@ public sealed class CreateCustomerRequest
|
||||
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
|
||||
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCustomerRequest
|
||||
@@ -58,6 +60,7 @@ public sealed class UpdateCustomerRequest
|
||||
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
|
||||
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
|
||||
public int? DefaultWarehouseId { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateCustomerStatusRequest
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace ERPCore.Dtos.Dashboard;
|
||||
/// Aggregate counts for the dashboard overview — mirrors the widget set in
|
||||
/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation,
|
||||
/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting,
|
||||
/// and open RFQs. Recent movements isn't here — it's just GET /stock/ledger with a small
|
||||
/// pageSize, no aggregation needed. A single computed-on-read object, not a stored
|
||||
/// entity — same as reorder alerts (docs/11 §5.7).
|
||||
/// open RFQs, pending leave requests, and overdue sales invoices. Also reused as small
|
||||
/// KPI tiles on individual list pages (Purchase Orders, GRN, Requisitions, RFQs, Stock
|
||||
/// Counts, Reorder Alerts, HRM Leave, Sales Invoices), not just the Dashboard home page.
|
||||
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no
|
||||
/// aggregation needed. A single computed-on-read object, not a stored entity — same as
|
||||
/// reorder alerts (docs/11 §5.7).
|
||||
/// </summary>
|
||||
public sealed record DashboardStatsDto(
|
||||
int LowStockAlerts,
|
||||
@@ -18,7 +21,14 @@ public sealed record DashboardStatsDto(
|
||||
int PendingGrns,
|
||||
int OpenRequisitions,
|
||||
int PendingCounts,
|
||||
int OpenRfqs);
|
||||
int OpenRfqs,
|
||||
int PendingLeaveRequests,
|
||||
int OverdueSalesInvoices,
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
|
||||
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
|
||||
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
|
||||
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed record GrnDto(
|
||||
/// <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, int BranchId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
|
||||
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
@@ -63,8 +63,9 @@ public sealed class CreateGrnLineInput
|
||||
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
|
||||
/// <summary>Trade discount percentage (0–100). Reduces the inventory cost.</summary>
|
||||
[Range(0, 100)] public decimal DiscountPct { get; set; }
|
||||
/// <summary>VAT percentage (0–100). Recoverable — does not affect stock value.</summary>
|
||||
[Range(0, 100)] public decimal VatPct { get; set; }
|
||||
/// <summary>VAT percentage (0–100). Recoverable — does not affect stock value. Explicit
|
||||
/// override; omit (null) to use the item's assigned TaxRate percentage instead.</summary>
|
||||
[Range(0, 100)] public decimal? VatPct { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,8 @@ using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Hrm;
|
||||
|
||||
public sealed record EmployeeDeleteResultDto(bool UserAccountDeleted, bool UserAccountDeactivated, bool AuthHexIdentityPurged = false);
|
||||
|
||||
public sealed record EmployeeListItemDto(
|
||||
int EmployeeId, string EmployeeCode, string FullName, string? Email,
|
||||
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
|
||||
|
||||
@@ -10,10 +10,11 @@ public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
int? TaxRateId, string? TaxRateName, decimal? TaxRatePercentage,
|
||||
decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status);
|
||||
EntityStatus Status, string? Barcode);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
@@ -31,10 +32,11 @@ public sealed record ItemDetailDto(
|
||||
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
|
||||
StockNature StockNature, TrackingMode TrackingMode,
|
||||
Warranty Warranty, int? WarrantyPeriodMonths,
|
||||
string? TaxClass, decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
int? TaxRateId, string? TaxRateName, decimal? TaxRatePercentage,
|
||||
decimal? SellingMinPrice, decimal? SellingMaxPrice, decimal? SellingFlatPrice,
|
||||
decimal? ContentQty, MeasureUnit? ContentUnit,
|
||||
decimal? ContentBaseQty, MeasureUnit? ContentBaseUnit,
|
||||
EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
EntityStatus Status, string? Barcode, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
|
||||
@@ -64,7 +66,8 @@ public sealed class CreateItemRequest
|
||||
[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>The tax rate sales/purchase lines for this item default to — see <see cref="Domain.Entities.TaxRate"/>.</summary>
|
||||
public int? TaxRateId { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
|
||||
@@ -77,6 +80,8 @@ public sealed class CreateItemRequest
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
/// <summary>Optional scan code for barcode/RFID lookup. Unlike <see cref="Sku"/>, not required.</summary>
|
||||
[StringLength(100)] public string? Barcode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemRequest
|
||||
@@ -96,7 +101,8 @@ public sealed class UpdateItemRequest
|
||||
[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>The tax rate sales/purchase lines for this item default to — see <see cref="Domain.Entities.TaxRate"/>.</summary>
|
||||
public int? TaxRateId { get; set; }
|
||||
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMinPrice { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal? SellingMaxPrice { get; set; }
|
||||
@@ -109,6 +115,8 @@ public sealed class UpdateItemRequest
|
||||
/// </summary>
|
||||
[Range(0.0001, double.MaxValue)] public decimal? ContentQty { get; set; }
|
||||
[EnumDataType(typeof(MeasureUnit))] public MeasureUnit? ContentUnit { get; set; }
|
||||
/// <summary>Optional scan code for barcode/RFID lookup. Unlike <see cref="Sku"/>, not required.</summary>
|
||||
[StringLength(100)] public string? Barcode { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemStatusRequest
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>An item photo's metadata. The bytes are served by
|
||||
/// <c>GET /items/{itemId}/images/{itemImageId}/content</c>.</summary>
|
||||
public sealed record ItemImageDto(
|
||||
int ItemImageId, int ItemId, string FileName, string ContentType, long SizeBytes,
|
||||
int SortOrder, DateTime UploadedAt);
|
||||
@@ -0,0 +1,27 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
|
||||
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
|
||||
/// separate endpoint from <see cref="ItemListItemDto"/>'s <c>GET /items</c> — that list
|
||||
/// also backs the GRN/PO/Requisition/RFQ item pickers, which don't need these aggregates
|
||||
/// computed on every keystroke.
|
||||
/// </summary>
|
||||
public sealed record ItemOverviewDto(
|
||||
int ItemId, string Sku, string Name,
|
||||
int CategoryId, int? SubCategoryId, int? BrandId,
|
||||
EntityStatus Status,
|
||||
decimal StockQty, decimal StockValue, bool IsLowStock,
|
||||
DateTime? LastPurchaseDate, int? LastSupplierId, string? LastSupplierName,
|
||||
decimal SalesQtyLast30Days);
|
||||
|
||||
/// <summary>Stock-state filter for the Items overview. <c>LowStock</c> uses the same rule
|
||||
/// as <see cref="ItemOverviewDto.IsLowStock"/>: on-hand at or below any warehouse's reorder point.</summary>
|
||||
public enum ItemStockFilter
|
||||
{
|
||||
InStock,
|
||||
OutOfStock,
|
||||
LowStock
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
/// <summary>Result of a barcode/RFID scan lookup — an item, or an item resolved via a
|
||||
/// specific batch/serial. See <see cref="Services.Interfaces.IItemService.ScanAsync"/>.</summary>
|
||||
public sealed record ItemScanResultDto(
|
||||
int ItemId, string Sku, string Name, TrackingMode TrackingMode, int BaseUomId,
|
||||
string MatchedBy, // "Item" | "Batch" | "Serial"
|
||||
int? BatchId, string? BatchNo, DateOnly? ExpiryDate,
|
||||
int? SerialId, string? SerialNo, string? SerialStatus);
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
public sealed record WarrantyPeriodDto(int WarrantyPeriodId, int Months, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>A new custom length, always in months (the client converts years).</summary>
|
||||
public sealed class CreateWarrantyPeriodRequest
|
||||
{
|
||||
[Required] public int? Months { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateWarrantyPeriodStatusRequest
|
||||
{
|
||||
[Required] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ERPCore.Dtos.Notifications;
|
||||
|
||||
public sealed record NotificationDto(
|
||||
int NotificationId,
|
||||
string ModuleKey,
|
||||
string Title,
|
||||
string Message,
|
||||
string? RelatedEntityType,
|
||||
int? RelatedEntityId,
|
||||
DateTime CreatedAt,
|
||||
bool IsRead);
|
||||
|
||||
public sealed record NotificationFeedDto(
|
||||
IReadOnlyList<NotificationDto> Notifications,
|
||||
int UnreadCount);
|
||||
@@ -12,14 +12,14 @@ public sealed record PurchaseInvoiceTotalsDto(
|
||||
|
||||
public sealed record PurchaseInvoiceDto(
|
||||
int PurchaseInvoiceId, string DocNo, string VendorInvoiceNo, DateTime VendorInvoiceDate,
|
||||
int VendorId, string VendorSnapshotName, string? VendorSnapshotTaxNo,
|
||||
int VendorId, int BranchId, 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,
|
||||
int VendorId, int BranchId, string VendorSnapshotName, PurchaseInvoiceStatus Status, int GrnCount,
|
||||
PurchaseInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
|
||||
public sealed record PurchaseInvoicePaymentDto(
|
||||
|
||||
@@ -12,13 +12,14 @@ public sealed record PoLineDto(
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
|
||||
public sealed record PurchaseOrderDto(
|
||||
int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
|
||||
|
||||
public sealed record PurchaseOrderSummaryDto(
|
||||
int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
|
||||
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals);
|
||||
|
||||
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
|
||||
|
||||
@@ -35,12 +36,13 @@ public sealed class CreatePurchaseOrderRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
public int? RequisitionId { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When true the PO is created in <c>Draft</c> (editable/deletable, not yet issued).
|
||||
/// When false (default) it auto-approves on creation, preserving the Requisition→PO
|
||||
/// and RFQ→PO flows unchanged (docs/11 §3.3).
|
||||
/// When false (default) it is issued immediately into <c>PendingApproval</c>, same as
|
||||
/// submitting a Draft (docs/11 §3.3).
|
||||
/// </summary>
|
||||
public bool SaveAsDraft { get; set; }
|
||||
}
|
||||
@@ -49,6 +51,7 @@ public sealed class UpdatePurchaseOrderRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
public int? RequisitionId { get; set; }
|
||||
public int BranchId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -56,3 +59,8 @@ public sealed class CancelPurchaseOrderRequest
|
||||
{
|
||||
[StringLength(500)] public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RejectPurchaseOrderRequest
|
||||
{
|
||||
[StringLength(500)] public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ namespace ERPCore.Dtos.Procurement;
|
||||
public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
|
||||
|
||||
public sealed record RequisitionDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, int BranchId,
|
||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||
|
||||
public sealed record RequisitionSummaryDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, int BranchId, DateTime CreatedAt, int LineCount);
|
||||
|
||||
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
|
||||
|
||||
@@ -26,4 +26,7 @@ public sealed class CreateRequisitionLineInput
|
||||
public sealed class CreateRequisitionRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<CreateRequisitionLineInput> Lines { get; set; } = new();
|
||||
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
|
||||
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
|
||||
public int BranchId { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ namespace ERPCore.Dtos.Procurement;
|
||||
public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
|
||||
|
||||
public sealed record RfqDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
||||
int RfqId, string DocNo, int RequisitionId, int BranchId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
||||
|
||||
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
|
||||
public sealed record RfqSummaryDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
|
||||
int RfqId, string DocNo, int RequisitionId, int BranchId, RfqStatus Status, int LineCount, int QuotationCount);
|
||||
|
||||
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ namespace ERPCore.Dtos.Rbac;
|
||||
/// 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);
|
||||
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
|
||||
|
||||
@@ -29,6 +29,22 @@ public sealed class AssignRolePermissionsRequest
|
||||
{
|
||||
public List<int> NavItemIds { get; set; } = new();
|
||||
public List<int> SubNavItemIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-nav/sub-nav-item action override, keyed by <c>NavItemId</c>/
|
||||
/// <c>SubNavItemId</c> and valued with a <see cref="RolePermissionAction"/> bitmask
|
||||
/// (e.g. <c>View|Create|Edit</c> = 7 to grant a module without Delete/Approve). An id
|
||||
/// present in <see cref="NavItemIds"/>/<see cref="SubNavItemIds"/> but absent here
|
||||
/// keeps today's default of <see cref="RolePermissionAction.All"/> — this only narrows,
|
||||
/// never grants access to an id that isn't also in those lists.
|
||||
/// </summary>
|
||||
public Dictionary<int, RolePermissionAction> NavItemActions { get; set; } = new();
|
||||
public Dictionary<int, RolePermissionAction> SubNavItemActions { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed record RolePermissionsDto(int RoleId, List<int> NavItemIds, List<int> SubNavItemIds);
|
||||
public sealed record RolePermissionsDto(
|
||||
int RoleId,
|
||||
List<int> NavItemIds,
|
||||
List<int> SubNavItemIds,
|
||||
Dictionary<int, RolePermissionAction> NavItemActions,
|
||||
Dictionary<int, RolePermissionAction> SubNavItemActions);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user