Compare commits

...

17 Commits

Author SHA1 Message Date
ImanThiyanga 3b87a6e421 Merge branch 'Dev' into feat/ci-cd-implementation
CI/CD - ERP Core / detect (pull_request) Waiting to run
CI/CD - ERP Core / backend (pull_request) Blocked by required conditions
CI/CD - ERP Core / frontend (pull_request) Blocked by required conditions
CI/CD - ERP Core / e2e-typecheck (pull_request) Blocked by required conditions
CI/CD - ERP Core / scripts (pull_request) Blocked by required conditions
CI/CD - ERP Core / ci-ok (pull_request) Blocked by required conditions
CI/CD - ERP Core / Build & Deploy (pull_request) Waiting to run
2026-09-16 18:30:22 +00:00
ImanThiyanga 2a7a44cc27 change in to merge
CI/CD - ERP Core / detect (pull_request) Failing after 13m11s
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m17s
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
2026-09-16 23:48:07 +05:30
ImanThiyanga c7cf63e7de initiating connection fixes 2026-09-16 23:47:47 +05:30
ImanThiyanga d1044e4728 final fix 2026-09-16 13:53:57 +05:30
ImanThiyanga d6f973202d Merge branch 'production' into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 10m38s
CI/CD - ERP Core / detect (pull_request) Failing after 11m30s
CI/CD - ERP Core / scripts (pull_request) Failing after 11m31s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 12m23s
CI/CD - ERP Core / frontend (pull_request) Failing after 13m14s
CI/CD - ERP Core / backend (pull_request) Failing after 14m6s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m59s
2026-09-16 06:36:19 +00:00
ImanThiyanga 1d5a5cf68e Merge pull request 'Feat/ci cd implementation' (#57) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / detect (pull_request) Has been cancelled
CI/CD - ERP Core / backend (pull_request) Has been cancelled
CI/CD - ERP Core / frontend (pull_request) Has been cancelled
CI/CD - ERP Core / e2e-typecheck (pull_request) Has been cancelled
CI/CD - ERP Core / scripts (pull_request) Has been cancelled
CI/CD - ERP Core / ci-ok (pull_request) Has been cancelled
CI/CD - ERP Core / Build & Deploy (pull_request) Has been cancelled
Reviewed-on: #57
2026-09-16 06:24:55 +00:00
ImanThiyanga 8e472ac996 initialising for ci-cd pipeline
CI/CD - ERP Core / scripts (pull_request) Failing after 12m21s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m13s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m5s
CI/CD - ERP Core / backend (pull_request) Failing after 14m57s
CI/CD - ERP Core / detect (pull_request) Failing after 13m42s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 12m47s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
2026-09-16 11:53:55 +05:30
ImanThiyanga 666979cf22 initiated ci-cd pipeline for the erp core with fronend changes to detect changes on both front and back 2026-09-16 11:53:17 +05:30
ashan_rusiru eb36e0acfa Merge pull request 'Users and employees arrangements' (#56) from users-and-employees-arrangements into Dev
Reviewed-on: #56
2026-09-14 05:23:25 +00:00
ashan_rusiru 0ff39e9e58 Merge pull request 'Users and employees arrangements' (#53) from users-and-employees-arrangements into production
Reviewed-on: #53
2026-09-13 07:52:34 +00:00
ashan_rusiru cbb610fc8b Merge branch 'production' into users-and-employees-arrangements 2026-09-13 07:52:24 +00:00
ashan_rusiru afa9928dcc Merge pull request 'bug fixing' (#55) from bug-fixing into Dev
Reviewed-on: #55
2026-09-13 07:52:06 +00:00
Sasanka 99aa395fb1 updeted 2026-09-13 08:30:18 +05:30
Sasanka cb41d503c4 emloyee and user fixings 2026-09-11 10:32:47 +05:30
Sasanka 629f3d1f44 bug fixing 2026-09-09 14:38:53 +05:30
ImanThiyanga b3b6a25ce8 Merge pull request 'Branch crud' (#52) from branch-crud into Dev
Reviewed-on: #52
2026-09-08 07:13:47 +00:00
ImanThiyanga b4993b202d Merge pull request 'Dev' (#45) from Dev into production
Reviewed-on: #45
2026-08-13 06:52:56 +00:00
177 changed files with 49990 additions and 1832 deletions
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
+10
View File
@@ -0,0 +1,10 @@
# Deployment assets execute on the Linux runner / VPS. Force LF no matter which OS commits them:
# a CRLF script fails with "/usr/bin/env: 'bash\r': No such file or directory".
*.sh text eol=lf
*.service text eol=lf
*.conf text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile text eol=lf
.dockerignore text eol=lf
*.env.example text eol=lf
+509
View File
@@ -0,0 +1,509 @@
# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy
# (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active".
#
# Which jobs run for which event:
# PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck /
# scripts (only the sides the PR touches) → ci-ok
# PR merged into production → build-and-deploy (checks are skipped)
# PR merged into Dev, or closed unmerged → nothing (every job skipped)
# Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes
#
# Checks: `ci-ok` is the single required status check for branch protection. It passes when every
# check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*.
#
# Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions →
# start → verify), plus: only the side whose folder changed in the PR is deployed, the database is
# backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback).
# Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow".
# The test environment is on hold (deploy/on-hold/).
#
# Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but
# the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with
# ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a
# trusted team member.
name: CI/CD - ERP Core
on:
pull_request:
branches:
- Dev
- production
types:
- opened
- synchronize
- reopened
- closed
workflow_dispatch:
inputs:
deploy_backend:
description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)"
type: boolean
default: false
deploy_frontend:
description: "Deploy the frontend"
type: boolean
default: false
# One group for the whole file, never cancelled: a deploy (or its migration) must not be interrupted
# by a PR push. Superseded check runs therefore queue instead of being cancelled; with the runner at
# capacity 1 they would run one after another anyway.
concurrency:
group: erp-core-cicd
cancel-in-progress: false
jobs:
# ════════════════════════════ Pull-request checks ════════════════════════════
detect:
name: detect
if: github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: linux
outputs:
backend: ${{ steps.detect.outputs.backend }}
frontend: ${{ steps.detect.outputs.frontend }}
e2e: ${{ steps.detect.outputs.e2e }}
scripts: ${{ steps.detect.outputs.scripts }}
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
git log -1 --format='checked out %h %s'
- name: Detect changed sides
id: detect
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
run: bash deploy/scripts/ci-detect.sh
backend:
name: backend
needs: detect
if: needs.detect.outputs.backend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Build, test, pending model changes
run: bash deploy/scripts/ci-backend.sh
- name: Destructive migration scan
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
BASE_REF: ${{ github.base_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: bash deploy/scripts/migration-scan.sh
frontend:
name: frontend
needs: detect
if: needs.detect.outputs.frontend == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
# Checks only (no image): build-and-deploy builds its own image with `docker build` after the merge.
- name: Type-check, lint, build
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=2 --memory=2500m \
-e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \
-v "$PWD/Frontend/erp-system:/app$z" -v erp-npm-cache:/root/.npm -w /app node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit && npx eslint . && npm run build'
e2e-typecheck:
name: e2e-typecheck
needs: detect
if: needs.detect.outputs.e2e == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: Type-check Playwright specs
run: |
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
docker run --rm --cpus=1 --memory=768m \
-v "$PWD/Testing/e2e:/e2e$z" -v erp-npm-cache:/root/.npm -w /e2e node:24-alpine \
sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit'
scripts:
name: scripts
needs: detect
if: needs.detect.outputs.scripts == 'true'
runs-on: linux
steps:
- name: Checkout PR head
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
SHA: ${{ github.event.pull_request.head.sha }}
REF: refs/pull/${{ github.event.pull_request.number }}/head
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
git init -q .
git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \
|| git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF"
git checkout -qf "$SHA" && git clean -ffdxq
- name: bash -n + shellcheck
run: |
for f in deploy/scripts/*.sh; do bash -n "$f"; done
z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi
files=""; for f in deploy/scripts/*.sh; do files="$files /mnt/${f#deploy/}"; done
# shellcheck disable=SC2086
docker run --rm -v "$PWD/deploy:/mnt$z" koalaman/shellcheck:stable \
--severity=warning --external-sources --source-path=SCRIPTDIR $files
ci-ok:
name: ci-ok
needs: [detect, backend, frontend, e2e-typecheck, scripts]
if: always() && github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: linux
steps:
- name: All required checks passed or were skipped
env:
RESULTS: >-
detect=${{ needs.detect.result }}
backend=${{ needs.backend.result }}
frontend=${{ needs.frontend.result }}
e2e-typecheck=${{ needs.e2e-typecheck.result }}
scripts=${{ needs.scripts.result }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
failed=0
for r in $RESULTS; do
case "${r#*=}" in
success|skipped) echo "ok $r" ;;
*) echo "FAILED $r"; failed=1 ;;
esac
done
if [ "$BASE_REF" = production ]; then
case "$HEAD_REF" in
Dev|hotfix/*) ;;
*) echo "PRs into production must come from Dev or hotfix/* (this one is from '$HEAD_REF')"; failed=1 ;;
esac
fi
exit "$failed"
# ════════════════════════════ Production deploy ════════════════════════════
# No `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge, so the
# deploy would never run. The pre-merge gate is branch protection requiring ci-ok.
build-and-deploy:
name: Build & Deploy
runs-on: linux
# base.ref matters: without it a PR merged into Dev would deploy production.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'production')
env:
PUBLISH_DIR: /home/erp/core/publish
BUILD_DIR: /tmp/erp-deploy
SERVICE: erpcore.service
SERVICE_USER: erpuser
BACKEND_PORT: "5220"
DB_NAME: ERPCore
DB_USER: erpcore
BACKUP_DIR: /var/backups/erpcore
WEB_IMAGE: erp-web:latest
WEB_CONTAINER: erp-web
WEB_PORT: "3020"
# ── Backend runtime config ────────────────────────────────────────────────────────────
# There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the
# "Install unit + write config" step writes them into a systemd drop-in on every backend
# deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend.
# The two secrets (DB password, GL API key) come from Gitea secrets, never from this file.
AUTHHEX_BASE_URL: "http://127.0.0.1:5433"
GL_BASE_URL: "http://127.0.0.1:5433/api/v1/"
STORAGE_ROOT: /var/lib/erpcore/hr-documents
LOG_PATH: /var/log/erpcore/erpcore-.log
SEED_DEMO_DATA: "false"
# Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to
# restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs).
AUTH_REQUIRED_USER_TYPE: ""
AUTH_REQUIRED_ROLE: ""
steps:
- name: Checkout code
env:
ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')"
url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git"
fetch() { git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$1"; }
git init -q .
# The exact merge commit, not the branch tip (a second merge may already have landed).
if [ -n "$MERGE_SHA" ] && fetch "$MERGE_SHA"; then :; else fetch production; fi
git checkout -qf FETCH_HEAD && git clean -ffdxq
# production as it was before this PR — the detect step compares against it.
if [ -n "$BASE_SHA" ]; then fetch "$BASE_SHA" || echo "could not fetch base $BASE_SHA — both sides will deploy"; fi
git log -1 --format='Deploying %h %s'
- name: Detect changes
id: changes
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
INPUT_BACKEND: ${{ inputs.deploy_backend }}
INPUT_FRONTEND: ${{ inputs.deploy_frontend }}
run: |
# A folder's git tree hash changes only when something inside it changed, so comparing
# production-before-the-PR with the merge result works for merge, squash and rebase merges.
changed() {
if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo true; return; fi
now="$(git rev-parse -q --verify "HEAD:$1" || true)"
before="$(git rev-parse -q --verify "$BASE_SHA:$1" || true)"
if [ "$now" != "$before" ]; then echo true; else echo false; fi
}
if [ "$EVENT" = workflow_dispatch ]; then
backend="${INPUT_BACKEND:-false}"
frontend="${INPUT_FRONTEND:-false}"
migrations="$backend" # unknown on a manual run → take the backup
else
backend="$(changed Backend/ERPCore)"
frontend="$(changed Frontend/erp-system)"
migrations="$(changed Backend/ERPCore/Migrations)"
fi
{
echo "backend=$backend"
echo "frontend=$frontend"
echo "migrations=$migrations"
} >> "$GITHUB_OUTPUT"
echo "backend=$backend frontend=$frontend migrations=$migrations"
if [ "$backend" != true ] && [ "$frontend" != true ]; then echo "Nothing to deploy."; fi
# ── Backend ──────────────────────────────────────────────────────────────────────────────
- name: Restore
if: steps.changes.outputs.backend == 'true'
run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64
- name: Publish
if: steps.changes.outputs.backend == 'true'
run: |
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
dotnet publish Backend/ERPCore/ERPCore.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
--no-restore \
-o "$BUILD_DIR"
- name: Backup database
if: steps.changes.outputs.backend == 'true' && steps.changes.outputs.migrations == 'true'
run: |
mkdir -p "$BACKUP_DIR"
pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)"
file="$BACKUP_DIR/$DB_NAME-$(date -u +%Y%m%dT%H%M%SZ).dump"
runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" > "$file.partial"
mv "$file.partial" "$file"
echo "Backup: $file"
find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete
- name: Install unit + write config
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
run: |
# Config lives in this workflow's env: block, not in a file on the server. It is written
# to a systemd drop-in here, before the service is stopped, so a bad value fails the job
# while the OLD release is still serving.
case "$AUTHHEX_BASE_URL$GL_BASE_URL" in
*CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;;
esac
[ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; }
[ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; }
case "$DB_PASSWORD" in
*";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;;
esac
# "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere.
publish="$(realpath -m "$PUBLISH_DIR")"
for path in "$STORAGE_ROOT" "$LOG_PATH"; do
case "$path" in
/*) ;;
*) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
esac
case "$(realpath -m "$path")/" in
"$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
esac
echo "ok $path"
done
# The unit is installed from the repo, so editing deploy/systemd/erpcore.service and
# deploying is enough — no manual copy step on the server.
install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service
install -d -m 755 /etc/systemd/system/erpcore.service.d
conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
# Values are quoted: systemd would otherwise split a password containing spaces.
umask 077
# Only the listening URL. Everything else is app configuration and lands in
# appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host
# configuration, the one setting appsettings cannot reliably provide.
cat > "$conf" <<EOF
[Service]
Environment="ASPNETCORE_URLS=http://127.0.0.1:$BACKEND_PORT"
EOF
chmod 600 "$conf"
systemctl daemon-reload
# Idempotent: creates the multi-user.target.wants symlink so the app survives a reboot.
systemctl enable "$SERVICE"
echo "✅ unit + drop-in installed ($(grep -c '^Environment=' "$conf") settings; values not printed)"
# Data dirs must exist and belong to the service user (Serilog fails silently otherwise).
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STORAGE_ROOT" "$(dirname "$LOG_PATH")"
- name: Stop service
if: steps.changes.outputs.backend == 'true'
run: systemctl stop "$SERVICE" || true
- name: Copy to deploy folder
if: steps.changes.outputs.backend == 'true'
run: |
mkdir -p "$PUBLISH_DIR"
rm -rf "${PUBLISH_DIR:?}"/*
cp -r "$BUILD_DIR"/. "$PUBLISH_DIR"/
- name: Fix ownership and permissions
if: steps.changes.outputs.backend == 'true'
run: |
chown -R "$SERVICE_USER:$SERVICE_USER" "$PUBLISH_DIR"
find "$PUBLISH_DIR" -type d -exec chmod 755 {} +
find "$PUBLISH_DIR" -type f -exec chmod 644 {} +
chmod +x "$PUBLISH_DIR/ERPCore"
# Written AFTER "Fix ownership" so it keeps mode 640 (root:erpuser) instead of being
# flattened to 644 — it holds the DB password and the GL API key.
- name: Write appsettings.Production.json
if: steps.changes.outputs.backend == 'true'
env:
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
run: |
# Deployed config lives in appsettings.Production.json, generated here from this
# workflow's env: block + the Gitea secrets. It is NOT in git (.gitignore ignores
# appsettings.*.json) and is rewritten on every backend deploy, which is why the
# publish folder being wiped does not matter. It MERGES over appsettings.json, so
# Auth:RsaPublicKeyXml, Issuer, Audience and FileStorage:MaxSizeBytes still come
# from the committed base file.
conf="$PUBLISH_DIR/appsettings.Production.json"
# python3 emits the JSON so a password containing " or \ is escaped correctly.
python3 - > "$conf" <<'PY'
import json, os
e = os.environ
print(json.dumps({
"ConnectionStrings": {
"DefaultConnection":
"Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s"
% (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]),
},
"AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]},
"GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]},
"Auth": {
"RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"],
"RequiredRoleCode": e["AUTH_REQUIRED_ROLE"],
},
"FileStorage": {"RootPath": e["STORAGE_ROOT"]},
"LogFile": {"Path": e["LOG_PATH"]},
"Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"},
}, indent=2))
PY
chown "root:$SERVICE_USER" "$conf"
chmod 640 "$conf"
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf"
echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)"
- name: Start service
if: steps.changes.outputs.backend == 'true'
run: |
systemctl reset-failed "$SERVICE" 2>/dev/null || true
systemctl start "$SERVICE" || echo "systemctl start returned an error — the verify step shows why"
# Baseline for the crash check in the verify step.
echo "RESTARTS_AT_START=$(systemctl show -p NRestarts --value "$SERVICE")" >> "$GITHUB_ENV"
- name: Verify backend
if: steps.changes.outputs.backend == 'true'
env:
HEALTH_TIMEOUT: "300" # migrations run at startup; a crash still fails within seconds
run: bash deploy/scripts/wait-healthy.sh
# ── Frontend (after the backend: a failed backend step stops the job here) ─────────────
- name: Build frontend image
if: steps.changes.outputs.frontend == 'true'
run: docker build -t "$WEB_IMAGE" Frontend/erp-system
- name: Stop & remove existing container
if: steps.changes.outputs.frontend == 'true'
run: |
docker stop "$WEB_CONTAINER" 2>/dev/null || true
docker rm "$WEB_CONTAINER" 2>/dev/null || true
- name: Start container
if: steps.changes.outputs.frontend == 'true'
run: |
docker run -d \
--name "$WEB_CONTAINER" \
-p "127.0.0.1:$WEB_PORT:3000" \
--memory=512m \
--restart unless-stopped \
"$WEB_IMAGE"
- name: Verify frontend
if: steps.changes.outputs.frontend == 'true'
run: |
for _ in $(seq 1 15); do
if curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; then
echo "✅ Frontend serving on 127.0.0.1:$WEB_PORT"
exit 0
fi
sleep 2
done
echo "❌ Frontend did not serve /login within 30s"
docker ps -a --filter "name=$WEB_CONTAINER" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker logs --tail 50 "$WEB_CONTAINER" || true
exit 1
- name: Remove old images
if: steps.changes.outputs.frontend == 'true'
run: |
docker image prune -f
docker builder prune -f --filter until=168h
+4
View File
@@ -36,4 +36,8 @@ Testing/e2e/test-results/
Testing/e2e/.auth/
Testing/e2e/blob-report/
# ── Python (Backend/smoke) ────────────────────────────────────────────
__pycache__/
*.pyc
@@ -11,6 +11,8 @@ 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
+14
View File
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore.Tests", "ERPCore.Tests\ERPCore.Tests.csproj", "{E451472F-550F-40A0-AE82-E24ECD6C647A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,18 @@ Global
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = Release|Any CPU
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.ActiveCfg = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.Build.0 = Debug|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.Build.0 = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.ActiveCfg = Release|Any CPU
{E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
private readonly IAuditService _audit;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
_audit = audit;
}
/// <summary>
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
return Ok(result.Body);
}
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
{
await _branches.DeleteAsync(branchId, ct);
return NoContent();
}
}
@@ -62,4 +62,14 @@ public sealed class DepartmentsController : ApiControllerBase
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int departmentId, CancellationToken ct)
{
await _departments.DeleteAsync(departmentId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class DesignationsController : ApiControllerBase
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int designationId, CancellationToken ct)
{
await _designations.DeleteAsync(designationId, ct);
return NoContent();
}
}
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
{
await _employees.DeleteAsync(employeeId, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -61,4 +61,14 @@ public sealed class EmploymentTypesController : ApiControllerBase
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int employmentTypeId, CancellationToken ct)
{
await _employmentTypes.DeleteAsync(employmentTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class HrDocumentTypesController : ApiControllerBase
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int hrDocumentTypeId, CancellationToken ct)
{
await _types.DeleteAsync(hrDocumentTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class LeaveTypesController : ApiControllerBase
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int leaveTypeId, CancellationToken ct)
{
await _leaveTypes.DeleteAsync(leaveTypeId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class SalaryComponentsController : ApiControllerBase
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int salaryComponentId, CancellationToken ct)
{
await _components.DeleteAsync(salaryComponentId, ct);
return NoContent();
}
}
@@ -61,4 +61,14 @@ public sealed class WorkShiftsController : ApiControllerBase
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int workShiftId, CancellationToken ct)
{
await _shifts.DeleteAsync(workShiftId, ct);
return NoContent();
}
}
+21 -3
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
@@ -16,12 +18,14 @@ public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
private readonly IAuditService _audit;
private readonly ICurrentUser _currentUser;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
{
_users = users;
_links = links;
_audit = audit;
_currentUser = currentUser;
}
@@ -33,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -65,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
}
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
+4 -2
View File
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
/// and cascades to every dependent HR record, blocked only if payroll history exists.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
+5
View File
@@ -22,6 +22,11 @@ public class Grn
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public int CreatedBy { get; set; }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
/// admin can see a user's device/login history — AuthHex's own session listing only
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
/// active sessions" view (that would need AuthHex to expose one).
/// </summary>
public class LoginActivity
{
public int LoginActivityId { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
public string? DeviceLabel { get; set; }
public DateTime LoginAt { get; set; }
}
@@ -17,6 +17,11 @@ public class SalesInvoice
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
@@ -19,6 +19,11 @@ public class StockAdjustment
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public int CreatedBy { get; set; }
@@ -16,6 +16,11 @@ public class StockCount
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from <see cref="Warehouse"/>.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
@@ -19,6 +19,11 @@ public class StockTransfer
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
/// <summary>Owning branch, set explicitly at creation — warehouses are shared across
/// branches, so branch scope can no longer be derived from src/dest warehouse.</summary>
public int BranchId { get; set; }
public Branch? Branch { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public int CreatedBy { get; set; }
+4 -1
View File
@@ -5,5 +5,8 @@ public enum AuditAction
{
Create,
Update,
Delete
Delete,
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
Login
}
+4
View File
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto(
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
public sealed record LoginActivityDto(
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
+5 -2
View File
@@ -15,13 +15,13 @@ public sealed record GrnLineDto(
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, string? GlJournalNo,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
@@ -81,6 +81,9 @@ public sealed class CreateGrnRequest
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -15,14 +15,14 @@ public sealed record SalesInvoiceTotalsDto(
public sealed record SalesInvoiceDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId,
string CustomerSnapshotName, string? CustomerSnapshotTaxNo, int WarehouseId, int BranchId,
SalesInvoiceType InvoiceType, SalesInvoiceStatus Status, int CreatedBy, DateTime CreatedAt,
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, SalesInvoiceType InvoiceType,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto(
@@ -65,6 +65,9 @@ public sealed class CreateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
@@ -73,6 +76,9 @@ public sealed class UpdateSalesInvoiceRequest
{
[Required] public int CustomerId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(SalesInvoiceType))] public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
[Required, MinLength(1)] public List<CreateSalesInvoiceLineRequest> Lines { get; set; } = new();
}
+5 -2
View File
@@ -8,13 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, string? GlJournalNo, DateTime? GlPostedAt,
IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount, string? GlJournalNo);
// Requests ----------------------------------------------------------------------
@@ -31,6 +31,9 @@ public sealed class CreateAdjustmentLineInput
public sealed class CreateAdjustmentRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
+5 -2
View File
@@ -8,12 +8,12 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CountId, string DocNo, int WarehouseId, int BranchId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, string? GlJournalNo, IReadOnlyList<int> LedgerRefs);
@@ -23,6 +23,9 @@ public sealed record CountPostResultDto(int CountId, CountStatus Status, int? Ad
public sealed class CreateCountRequest
{
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch — warehouses are shared, so this is what actually scopes the
/// document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
}
+5 -2
View File
@@ -9,12 +9,12 @@ public sealed record TransferLineDto(
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
@@ -42,6 +42,9 @@ public sealed class CreateTransferRequest
{
[Required] public int SrcWarehouseId { get; set; }
[Required] public int DestWarehouseId { get; set; }
/// <summary>Owning branch for this transfer — warehouses are shared, so this is what
/// actually scopes the document (resolved via IBranchConfigService).</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
+5
View File
@@ -53,5 +53,10 @@ public sealed class UpdateUserRoleRequest
[Required] public int RoleId { get; set; }
}
public sealed class UpdateUserStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
@@ -0,0 +1,39 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
/// login-device history — not a full parsing library, just enough to be readable
/// (docs call this out: exact device model detection is out of scope).
/// </summary>
public static class UserAgentLabeler
{
public static string Label(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
var os = DetectOs(userAgent);
var browser = DetectBrowser(userAgent);
return $"{browser} on {os}";
}
private static string DetectOs(string ua)
{
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
return "an unknown OS";
}
private static string DetectBrowser(string ua)
{
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
return "an unknown browser";
}
}
@@ -18,6 +18,11 @@ public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configur
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
// The base appsettings.json ships the key blank; name it here rather than forward GL's 401
// for an empty X-Api-Key (deployed environments already refuse to start without it).
if (string.IsNullOrWhiteSpace(_apiKey))
throw new DomainException(ErrorCodes.GlServiceUnavailable, "GeneralLedgerService:ApiKey is not configured.", 503);
var relativeUri = path.TrimStart('/') + queryString;
using var request = new HttpRequestMessage(method, relativeUri);
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
@@ -21,7 +21,7 @@ public static class AuditScribe
private static readonly HashSet<Type> Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer),
typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
];
private static readonly JsonSerializerOptions Json = new()
@@ -22,11 +22,13 @@ public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Branch).WithMany().HasForeignKey(g => g.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.PurchaseInvoice).WithMany().HasForeignKey(g => g.PurchaseInvoiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
builder.HasIndex(g => g.BranchId);
}
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
{
public void Configure(EntityTypeBuilder<LoginActivity> builder)
{
builder.ToTable("login_activities");
builder.HasKey(l => l.LoginActivityId);
builder.Property(l => l.IpAddress).HasMaxLength(64);
builder.Property(l => l.UserAgent).HasMaxLength(500);
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
builder.Property(l => l.LoginAt).IsRequired();
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(l => new { l.UserId, l.LoginAt });
}
}
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
// NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
// Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
);
}
}
@@ -30,6 +30,11 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
.HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch)
.WithMany()
.HasForeignKey(x => x.BranchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.InvoiceType)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesInvoiceType.B2C);
@@ -47,6 +52,7 @@ public sealed class SalesInvoiceConfiguration : IEntityTypeConfiguration<SalesIn
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.InvoiceDate);
builder.HasIndex(x => x.BranchId);
builder.HasMany(x => x.Lines)
.WithOne(x => x.SalesInvoice)
@@ -21,7 +21,10 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Branch).WithMany().HasForeignKey(a => a.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => a.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCoun
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Branch).WithMany().HasForeignKey(c => c.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
builder.HasIndex(c => c.BranchId);
}
}
@@ -20,9 +20,11 @@ public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockT
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Branch).WithMany().HasForeignKey(t => t.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
builder.HasIndex(t => t.BranchId);
}
}
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
// never existed in the database before now, so "stock" is still bypassed frontend-side
// in AppSidebar.tsx until roles are actually granted these permissions.
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
);
}
}
@@ -39,17 +39,29 @@ public static class DataSeeder
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
/// <param name="includeDemoData">
/// Also seed demo rows — sample warehouses/UOMs/category/items, an on-hand stock layer,
/// customers, bundle and draft sales documents (<c>Seed:DemoData</c>). On for Development
/// and the test deployment (the E2E suite relies on MAIN/SHOP, PCS/BOX, General Goods);
/// never for release, which gets reference data only (docs/40-DEPLOYMENT.md).
/// </param>
public static async Task SeedAsync(ErpDbContext db, bool includeDemoData, CancellationToken ct = default)
{
// Reference data — every environment.
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
// dirty |= await SeedCompanyProfileAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
if (includeDemoData)
{
dirty |= await SeedSalesMastersAsync(db, ct);
dirty |= await SeedSalesStockAsync(db, ct);
dirty |= await SeedSalesAsync(db, ct);
}
if (dirty) await db.SaveChangesAsync(ct);
}
@@ -344,9 +356,9 @@ public static class DataSeeder
}
/// <summary>
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
/// a couple of customer rows, current-year document counters, and a few draft
/// invoice/slip samples when the required master data already exists.
/// Seeds the demo sales data used for UI/backend development: a couple of customer
/// rows and a few draft invoice/slip samples when the required master data already
/// exists. (Current-year document counters are reference data, seeded in SeedAsync.)
/// This intentionally never clears or rewrites any existing rows.
/// </summary>
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
@@ -354,7 +366,6 @@ public static class DataSeeder
var dirty = false;
dirty |= await SeedSalesCustomersAsync(db, ct);
dirty |= await SeedSalesSequencesAsync(db, ct);
dirty |= await SeedSampleSalesDocsAsync(db, ct);
try
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddBranchIdToDocuments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_transfers",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_counts",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "stock_adjustments",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "sales_invoices",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "BranchId",
table: "grns",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices",
column: "BranchId");
migrationBuilder.CreateIndex(
name: "IX_grns_BranchId",
table: "grns",
column: "BranchId");
// Backfill from each document's existing warehouse — warehouses were the sole
// branch anchor until now, so this preserves current branch assignment exactly.
// (Warehouse.BranchId is NOT NULL, so every row is resolvable; the WHERE BranchId = 0
// guard just keeps this idempotent if the migration is ever re-run against partial state.)
migrationBuilder.Sql(@"
UPDATE grns g SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = g.""WarehouseId"" AND g.""BranchId"" = 0;
UPDATE sales_invoices x SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = x.""WarehouseId"" AND x.""BranchId"" = 0;
UPDATE stock_counts c SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = c.""WarehouseId"" AND c.""BranchId"" = 0;
UPDATE stock_adjustments a SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = a.""WarehouseId"" AND a.""BranchId"" = 0;
UPDATE stock_transfers t SET ""BranchId"" = w.""BranchId""
FROM warehouses w WHERE w.""WarehouseId"" = t.""SrcWarehouseId"" AND t.""BranchId"" = 0;
");
migrationBuilder.AddForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers",
column: "BranchId",
principalTable: "hr_branches",
principalColumn: "BranchId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_grns_hr_branches_BranchId",
table: "grns");
migrationBuilder.DropForeignKey(
name: "FK_sales_invoices_hr_branches_BranchId",
table: "sales_invoices");
migrationBuilder.DropForeignKey(
name: "FK_stock_adjustments_hr_branches_BranchId",
table: "stock_adjustments");
migrationBuilder.DropForeignKey(
name: "FK_stock_counts_hr_branches_BranchId",
table: "stock_counts");
migrationBuilder.DropForeignKey(
name: "FK_stock_transfers_hr_branches_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_transfers_BranchId",
table: "stock_transfers");
migrationBuilder.DropIndex(
name: "IX_stock_counts_BranchId",
table: "stock_counts");
migrationBuilder.DropIndex(
name: "IX_stock_adjustments_BranchId",
table: "stock_adjustments");
migrationBuilder.DropIndex(
name: "IX_sales_invoices_BranchId",
table: "sales_invoices");
migrationBuilder.DropIndex(
name: "IX_grns_BranchId",
table: "grns");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_transfers");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_counts");
migrationBuilder.DropColumn(
name: "BranchId",
table: "stock_adjustments");
migrationBuilder.DropColumn(
name: "BranchId",
table: "sales_invoices");
migrationBuilder.DropColumn(
name: "BranchId",
table: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class MoveVendorsUnderProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Order matters: Permission.NavItemId cascades on delete, so the permission row must be
// repointed off NavItemId 3 (via the new sub_nav_item) before that nav_item is deleted —
// otherwise the delete cascades and removes the permission row first.
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 30, "procurement.vendors", "/dashboard/vendors", null, "Vendors", 4, 0 });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:procurement.vendors", null, 30 });
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 30);
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder", "Status" },
values: new object[] { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3, "Active" });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 3,
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
values: new object[] { "NAV:vendors", 3, null });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddStockSubNavItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 31, "stock.ledger", "/dashboard/stock/ledger", null, "Stock Ledger", 6, 1 },
{ 32, "stock.enquiry", "/dashboard/stock/enquiry", null, "Stock Enquiry", 6, 2 },
{ 33, "stock.valuation", "/dashboard/stock/valuation", null, "Valuation", 6, 3 },
{ 34, "stock.transfers", "/dashboard/stock/transfers", null, "Transfers", 6, 4 },
{ 35, "stock.adjustments", "/dashboard/stock/adjustments", null, "Adjustments", 6, 5 },
{ 36, "stock.counts", "/dashboard/stock/counts", null, "Counts", 6, 6 },
{ 37, "stock.reorder-alerts", "/dashboard/stock/reorder-alerts", null, "Reorder Alerts", 6, 7 },
{ 38, "stock.wastage", "/dashboard/stock/wastage", null, "Wastage", 6, 8 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 41, "NAV:stock.ledger", null, 31 },
{ 42, "NAV:stock.enquiry", null, 32 },
{ 43, "NAV:stock.valuation", null, 33 },
{ 44, "NAV:stock.transfers", null, 34 },
{ 45, "NAV:stock.adjustments", null, 35 },
{ 46, "NAV:stock.counts", null, 36 },
{ 47, "NAV:stock.reorder-alerts", null, 37 },
{ 48, "NAV:stock.wastage", null, 38 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 42);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 43);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 44);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 45);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 46);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 47);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 48);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 31);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 32);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 33);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 34);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 38);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddSalesSubNavItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 49, "NAV:sales.bundle-templates", null, 23 });
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-templates", "/dashboard/sales/bundles/templates", "Bundle Templates", 2 });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 40, "sales.invoices", "/dashboard/sales/invoices", null, "Sales", 13, 1 },
{ 41, "sales.free-issues", "/dashboard/sales/free-issues", null, "Free Issues", 13, 3 },
{ 42, "sales.day-end", "/dashboard/sales/day-end", null, "Day End", 13, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 50, "NAV:sales.invoices", null, 40 },
{ 51, "NAV:sales.free-issues", null, 41 },
{ 52, "NAV:sales.day-end", null, 42 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 49);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 50);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 51);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 52);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 40);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 41);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 42);
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 23,
columns: new[] { "Code", "Href", "Label", "SortOrder" },
values: new object[] { "sales.bundle-sales", "/dashboard/sales/bundles", "Bundle Sales", 1 });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddLoginActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "login_activities",
columns: table => new
{
LoginActivityId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
IpAddress = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
DeviceLabel = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
LoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_login_activities", x => x.LoginActivityId);
table.ForeignKey(
name: "FK_login_activities_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_login_activities_UserId_LoginAt",
table: "login_activities",
columns: new[] { "UserId", "LoginAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "login_activities");
}
}
}
@@ -1447,6 +1447,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GrnId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -1493,6 +1496,8 @@ namespace ERPCore.Migrations
b.HasKey("GrnId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -2166,6 +2171,39 @@ namespace ERPCore.Migrations
b.ToTable("hr_loan_installments", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.Property<int>("LoginActivityId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LoginActivityId"));
b.Property<string>("DeviceLabel")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("LoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserAgent")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("LoginActivityId");
b.HasIndex("UserId", "LoginAt");
b.ToTable("login_activities", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
{
b.Property<int>("NavItemId")
@@ -2229,15 +2267,6 @@ namespace ERPCore.Migrations
Status = "Active"
},
new
{
NavItemId = 3,
Code = "vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
SortOrder = 3,
Status = "Active"
},
new
{
NavItemId = 4,
Code = "procurement",
@@ -2761,8 +2790,8 @@ namespace ERPCore.Migrations
new
{
PermissionId = 3,
Code = "NAV:vendors",
NavItemId = 3
Code = "NAV:procurement.vendors",
SubNavItemId = 30
},
new
{
@@ -2985,6 +3014,78 @@ namespace ERPCore.Migrations
PermissionId = 40,
Code = "NAV:settings.branches",
SubNavItemId = 29
},
new
{
PermissionId = 41,
Code = "NAV:stock.ledger",
SubNavItemId = 31
},
new
{
PermissionId = 42,
Code = "NAV:stock.enquiry",
SubNavItemId = 32
},
new
{
PermissionId = 43,
Code = "NAV:stock.valuation",
SubNavItemId = 33
},
new
{
PermissionId = 44,
Code = "NAV:stock.transfers",
SubNavItemId = 34
},
new
{
PermissionId = 45,
Code = "NAV:stock.adjustments",
SubNavItemId = 35
},
new
{
PermissionId = 46,
Code = "NAV:stock.counts",
SubNavItemId = 36
},
new
{
PermissionId = 47,
Code = "NAV:stock.reorder-alerts",
SubNavItemId = 37
},
new
{
PermissionId = 48,
Code = "NAV:stock.wastage",
SubNavItemId = 38
},
new
{
PermissionId = 49,
Code = "NAV:sales.bundle-templates",
SubNavItemId = 23
},
new
{
PermissionId = 50,
Code = "NAV:sales.invoices",
SubNavItemId = 40
},
new
{
PermissionId = 51,
Code = "NAV:sales.free-issues",
SubNavItemId = 41
},
new
{
PermissionId = 52,
Code = "NAV:sales.day-end",
SubNavItemId = 42
});
});
@@ -4187,6 +4288,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4279,6 +4383,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceId");
b.HasIndex("BranchId");
b.HasIndex("CreatorUserId");
b.HasIndex("CustomerId");
@@ -4859,6 +4965,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("AdjustmentId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -4896,6 +5005,8 @@ namespace ERPCore.Migrations
b.HasKey("AdjustmentId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -4958,6 +5069,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<string>("CountType")
.IsRequired()
.HasMaxLength(20)
@@ -4990,6 +5104,8 @@ namespace ERPCore.Migrations
b.HasKey("CountId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DocNo")
@@ -5182,6 +5298,9 @@ namespace ERPCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("TransferId"));
b.Property<int>("BranchId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -5212,6 +5331,8 @@ namespace ERPCore.Migrations
b.HasKey("TransferId");
b.HasIndex("BranchId");
b.HasIndex("CreatedBy");
b.HasIndex("DestWarehouseId");
@@ -5477,15 +5598,55 @@ namespace ERPCore.Migrations
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-sales",
Href = "/dashboard/sales/bundles",
Label = "Bundle Sales",
SubNavItemId = 40,
Code = "sales.invoices",
Href = "/dashboard/sales/invoices",
Label = "Sales",
NavItemId = 13,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 23,
Code = "sales.bundle-templates",
Href = "/dashboard/sales/bundles/templates",
Label = "Bundle Templates",
NavItemId = 13,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 41,
Code = "sales.free-issues",
Href = "/dashboard/sales/free-issues",
Label = "Free Issues",
NavItemId = 13,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 42,
Code = "sales.day-end",
Href = "/dashboard/sales/day-end",
Label = "Day End",
NavItemId = 13,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 30,
Code = "procurement.vendors",
Href = "/dashboard/vendors",
Label = "Vendors",
NavItemId = 4,
SortOrder = 0,
Status = "Active"
},
new
{
SubNavItemId = 17,
Code = "procurement.requisitions",
@@ -5664,6 +5825,86 @@ namespace ERPCore.Migrations
NavItemId = 14,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 31,
Code = "stock.ledger",
Href = "/dashboard/stock/ledger",
Label = "Stock Ledger",
NavItemId = 6,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 32,
Code = "stock.enquiry",
Href = "/dashboard/stock/enquiry",
Label = "Stock Enquiry",
NavItemId = 6,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 33,
Code = "stock.valuation",
Href = "/dashboard/stock/valuation",
Label = "Valuation",
NavItemId = 6,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 34,
Code = "stock.transfers",
Href = "/dashboard/stock/transfers",
Label = "Transfers",
NavItemId = 6,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 35,
Code = "stock.adjustments",
Href = "/dashboard/stock/adjustments",
Label = "Adjustments",
NavItemId = 6,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 36,
Code = "stock.counts",
Href = "/dashboard/stock/counts",
Label = "Counts",
NavItemId = 6,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 37,
Code = "stock.reorder-alerts",
Href = "/dashboard/stock/reorder-alerts",
Label = "Reorder Alerts",
NavItemId = 6,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 38,
Code = "stock.wastage",
Href = "/dashboard/stock/wastage",
Label = "Wastage",
NavItemId = 6,
SortOrder = 8,
Status = "Active"
});
});
@@ -6380,6 +6621,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -6408,6 +6655,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("PurchaseInvoice");
@@ -6585,6 +6834,17 @@ namespace ERPCore.Migrations
b.Navigation("PayrollRun");
});
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
@@ -7142,6 +7402,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatorUserId");
@@ -7158,6 +7424,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Customer");
@@ -7423,6 +7691,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7441,6 +7715,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("ReasonCode");
@@ -7484,6 +7760,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7496,6 +7778,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("Warehouse");
@@ -7603,6 +7887,12 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
.WithMany()
.HasForeignKey("BranchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
.WithMany()
.HasForeignKey("CreatedBy")
@@ -7621,6 +7911,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Branch");
b.Navigation("Creator");
b.Navigation("DestWarehouse");
+46 -8
View File
@@ -15,6 +15,7 @@ using ERPCore.Services.Production;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.OpenApi;
@@ -23,10 +24,32 @@ using Serilog;
var builder = WebApplication.CreateBuilder(args);
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
// Fail fast on missing deployment config. Outside Development these arrive as environment
// variables (docs/40-DEPLOYMENT.md §5); a blank value would otherwise surface later as an
// unrelated crash (e.g. `new Uri("")` for AuthHex:BaseUrl) instead of naming the key.
if (!builder.Environment.IsDevelopment())
{
string[] requiredKeys =
[
"ConnectionStrings:DefaultConnection",
"AuthHex:BaseUrl",
"GeneralLedgerService:BaseUrl",
"GeneralLedgerService:ApiKey",
"Auth:RsaPublicKeyXml",
];
var missing = requiredKeys.Where(k => string.IsNullOrWhiteSpace(builder.Configuration[k])).ToArray();
if (missing.Length > 0)
throw new InvalidOperationException(
$"Missing required configuration for environment '{builder.Environment.EnvironmentName}': " +
$"{string.Join(", ", missing)}. Set them as environment variables (':' becomes '__', " +
"e.g. ConnectionStrings__DefaultConnection).");
}
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file. The path is
// configurable so deployed instances log outside their release folder (LogFile:Path).
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
.WriteTo.File(ctx.Configuration["LogFile:Path"] ?? "logs/erpcore-.log", rollingInterval: RollingInterval.Day));
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
builder.Services.AddControllers()
@@ -49,8 +72,10 @@ builder.Services.AddErpJwtAuth(builder.Configuration);
// AuthController proxy → AuthHex (docs/11 §2.0)
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
{
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
// Blank counts as missing: the base appsettings.json ships these keys empty on purpose.
var baseUrl = builder.Configuration["AuthHex:BaseUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException("AuthHex:BaseUrl is not configured (set it in appsettings.Development.json locally).");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
@@ -60,8 +85,9 @@ builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
{
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"];
if (string.IsNullOrWhiteSpace(baseUrl))
throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured (set it in appsettings.Development.json locally).");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
@@ -205,7 +231,10 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
try
{
await DataSeeder.SeedAsync(db);
// Demo rows (sample catalog, stock, customers, sales docs) default on only in Development;
// the test deployment opts in with Seed__DemoData=true, release never does.
var includeDemoData = app.Configuration.GetValue("Seed:DemoData", app.Environment.IsDevelopment());
await DataSeeder.SeedAsync(db, includeDemoData);
}
catch (Exception ex)
{
@@ -213,9 +242,18 @@ using (var scope = app.Services.CreateScope())
}
}
// Behind the co-located Apache reverse proxy (docs/40-DEPLOYMENT.md): take the client IP and
// scheme from X-Forwarded-*. Only loopback proxies are trusted by default, which is exactly it.
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
// Off in Production only. The test deployment (Staging) keeps it; it is still not public there
// because Apache proxies only /api/ — reach it through an SSH tunnel to the backend port.
if (!app.Environment.IsProduction())
{
app.UseSwagger();
app.UseSwaggerUI();
+10 -4
View File
@@ -32,6 +32,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -41,7 +42,7 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_adjustments = adjustments;
@@ -52,6 +53,7 @@ public sealed class AdjustmentService : IAdjustmentService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -105,14 +107,15 @@ public sealed class AdjustmentService : IAdjustmentService
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the adjustment carries its own owning BranchId.
if (branchId is not null) q = q.Where(a => a.BranchId == branchId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count, a.GlJournalNo))
.ToListAsync(ct);
@@ -145,6 +148,8 @@ public sealed class AdjustmentService : IAdjustmentService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment)
@@ -169,6 +174,7 @@ public sealed class AdjustmentService : IAdjustmentService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -196,7 +202,7 @@ public sealed class AdjustmentService : IAdjustmentService
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
+56 -1
View File
@@ -1,7 +1,10 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
{
private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal;
private readonly IRepository<User> _users;
private readonly IRepository<LoginActivity> _loginActivities;
private readonly IUnitOfWork _uow;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
public AuditService(
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
{
_logs = logs;
_journal = journal;
_users = users;
_loginActivities = loginActivities;
_uow = uow;
}
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
{
var userId = await _users.Query().AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => (int?)u.UserId)
.FirstOrDefaultAsync(ct);
// JIT provisioning may not have created the local shadow row yet on a brand-new
// account's very first login — nothing to attach the log to in that case.
if (userId is null) return;
var now = DateTime.UtcNow;
await _logs.AddAsync(new AuditLog
{
UserId = userId.Value,
EntityType = "User",
EntityId = userId.Value,
Action = AuditAction.Login,
ChangeSet = "{}",
CreatedAt = now
}, ct);
await _loginActivities.AddAsync(new LoginActivity
{
UserId = userId.Value,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceLabel = UserAgentLabeler.Label(userAgent),
LoginAt = now
}, ct);
await _uow.SaveChangesAsync(ct);
}
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
{
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LoginActivityId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
.ToListAsync(ct);
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
}
}
+11 -4
View File
@@ -34,6 +34,7 @@ public sealed class CountService : ICountService
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -43,7 +44,7 @@ public sealed class CountService : ICountService
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_counts = counts;
@@ -55,6 +56,7 @@ public sealed class CountService : ICountService
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
@@ -108,13 +110,14 @@ public sealed class CountService : ICountService
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the count carries its own owning BranchId.
if (branchId is not null) q = q.Where(c => c.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
@@ -133,6 +136,8 @@ public sealed class CountService : ICountService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
@@ -153,6 +158,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
BranchId = branchId,
CountType = request.CountType,
Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -222,6 +228,7 @@ public sealed class CountService : ICountService
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
BranchId = count.BranchId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
@@ -250,7 +257,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.CountId, c.DocNo, c.WarehouseId, c.BranchId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+2 -2
View File
@@ -62,11 +62,11 @@ public sealed class DashboardService : IDashboardService
.ToList();
var grns = _grns.Query().AsNoTracking();
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
if (branchId is not null) grns = grns.Where(g => g.BranchId == branchId);
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
var counts = _counts.Query().AsNoTracking();
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
if (branchId is not null) counts = counts.Where(c => c.BranchId == branchId);
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
+11 -7
View File
@@ -38,6 +38,7 @@ public sealed class GrnService : IGrnService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
@@ -49,8 +50,8 @@ public sealed class GrnService : IGrnService
IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
{
_grns = grns;
_pos = pos;
@@ -65,6 +66,7 @@ public sealed class GrnService : IGrnService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
@@ -86,15 +88,14 @@ public sealed class GrnService : IGrnService
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
@@ -116,6 +117,8 @@ public sealed class GrnService : IGrnService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
int vendorId;
@@ -211,6 +214,7 @@ public sealed class GrnService : IGrnService
PoId = request.PoId,
VendorId = vendorId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
@@ -448,7 +452,7 @@ public sealed class GrnService : IGrnService
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
_branches.Remove(branch);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
}
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
}
@@ -127,6 +127,22 @@ public sealed class DepartmentService : IDepartmentService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int departmentId, CancellationToken ct = default)
{
var dept = await _departments.GetByIdAsync(departmentId, ct)
?? throw new NotFoundException($"Department {departmentId} was not found.");
_departments.Remove(dept);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This department is referenced by other records (sub-departments or employees) and cannot be deleted.", 409);
}
}
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
{
@@ -101,5 +101,21 @@ public sealed class DesignationService : IDesignationService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int designationId, CancellationToken ct = default)
{
var designation = await _designations.GetByIdAsync(designationId, ct)
?? throw new NotFoundException($"Designation {designationId} was not found.");
_designations.Remove(designation);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This designation is referenced by other records and cannot be deleted.", 409);
}
}
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
}
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IRepository<EmployeeDocument> _documents;
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<LeaveBalance> _leaveBalances;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IFileStorageService _fileStorage;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_documents = documents;
_salaryStructures = salaryStructures;
_loans = loans;
_leaveBalances = leaveBalances;
_payrollLines = payrollLines;
_fileStorage = fileStorage;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow;
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
// A linked system-user account survives the employee row (Employee.UserId → User is
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
// with no staff record behind it. The User row itself is never removed.
if (employee.UserId is not null)
{
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
}
// Bank details and documents cascade at the DB level, but documents also have a
// physical file on disk that cascading the row would silently orphan — delete those
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
// they must be removed before the employee row itself will delete.
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
foreach (var doc in documents)
{
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
_documents.Remove(doc);
}
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
foreach (var loan in loans) _loans.Remove(loan);
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
_employees.Remove(employee);
await _uow.SaveChangesAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
{
return await _bankDetails.Query().AsNoTracking()
@@ -101,5 +101,21 @@ public sealed class EmploymentTypeService : IEmploymentTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employmentTypeId, CancellationToken ct = default)
{
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
_employmentTypes.Remove(et);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This employment type is referenced by other records and cannot be deleted.", 409);
}
}
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
}
@@ -108,6 +108,22 @@ public sealed class HrDocumentTypeService : IHrDocumentTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This document type is referenced by other records and cannot be deleted.", 409);
}
}
private static HrDocumentTypeDto Map(HrDocumentType t) => new(
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -43,6 +43,11 @@ public sealed class LeaveRequestService : ILeaveRequestService
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
if (status is not null) q = q.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%") || EF.Functions.ILike(r.Employee!.FullName, $"%{term}%"));
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.CreatedAt)
@@ -113,6 +113,22 @@ public sealed class LeaveTypeService : ILeaveTypeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int leaveTypeId, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
_types.Remove(type);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This leave type is referenced by other records and cannot be deleted.", 409);
}
}
private static LeaveTypeDto Map(LeaveType t) => new(
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
@@ -107,6 +107,22 @@ public sealed class SalaryComponentService : ISalaryComponentService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int salaryComponentId, CancellationToken ct = default)
{
var component = await _components.GetByIdAsync(salaryComponentId, ct)
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
_components.Remove(component);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This salary component is referenced by other records and cannot be deleted.", 409);
}
}
private static SalaryComponentDto Map(SalaryComponent c) => new(
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
}
@@ -120,6 +120,22 @@ public sealed class WorkShiftService : IWorkShiftService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int workShiftId, CancellationToken ct = default)
{
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
_shifts.Remove(shift);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This work shift is referenced by other records and cannot be deleted.", 409);
}
}
private static WorkShiftDto Map(WorkShift w) => new(
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API.
/// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
/// </summary>
public interface IAuditService
{
@@ -14,4 +17,12 @@ public interface IAuditService
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
/// AuthHex identity, if one exists yet.</summary>
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IBranchService
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int branchId, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IDepartmentService
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int departmentId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface IDesignationService
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int designationId, CancellationToken ct = default);
}
@@ -15,6 +15,14 @@ public interface IEmployeeService
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
/// <summary>
/// Permanently deletes the employee and every dependent record (bank details, documents
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
/// 409 if the employee has any payroll history, since that's financial/audit record that
/// must never be destroyed.
/// </summary>
Task DeleteAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface IEmploymentTypeService
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int employmentTypeId, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IHrDocumentTypeService
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int hrDocumentTypeId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface ILeaveTypeService
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int leaveTypeId, CancellationToken ct = default);
}
@@ -12,4 +12,5 @@ public interface ISalaryComponentService
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int salaryComponentId, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
/// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the
/// credential itself stays live upstream; this only revokes the account's
/// standing inside ERPCore.
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
}
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService
{
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
@@ -12,4 +12,5 @@ public interface IWorkShiftService
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int workShiftId, CancellationToken ct = default);
}
@@ -26,6 +26,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private readonly ISalesMappingService _mapping;
private readonly ISalesDocumentWorkflowService _workflow;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
@@ -33,7 +34,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
IRepository<SalesInvoice> invoices, IRepository<Customer> customers, IRepository<Item> items,
IRepository<Uom> uoms, IRepository<Warehouse> warehouses, ISalesDomainService sales, ISalesPostingService posting, ISalesMappingService mapping,
ISalesDocumentWorkflowService workflow,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow)
ICurrentUser currentUser, IBranchConfigService branchConfig, INumberSequenceService numbers, IUnitOfWork uow)
{
_invoices = invoices;
_customers = customers;
@@ -45,6 +46,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_mapping = mapping;
_workflow = workflow;
_currentUser = currentUser;
_branchConfig = branchConfig;
_numbers = numbers;
_uow = uow;
}
@@ -60,9 +62,8 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
if (status is not null) q = q.Where(x => x.Status == status);
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
// Sales carries no BranchId of its own — it inherits branch scope transitively via
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -83,6 +84,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default)
{
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
var customerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -96,6 +98,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
InvoiceDate = DateTime.UtcNow,
CustomerId = request.CustomerId,
WarehouseId = request.WarehouseId,
BranchId = branchId,
InvoiceType = request.InvoiceType,
Status = SalesInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
@@ -119,6 +122,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
await _sales.ValidateSalesHeaderAsync(request.CustomerId, request.WarehouseId, null, false, ct);
invoice.CustomerId = request.CustomerId;
invoice.WarehouseId = request.WarehouseId;
invoice.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
invoice.InvoiceType = request.InvoiceType;
invoice.CustomerSnapshotName = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.DisplayName ?? x.Name).FirstAsync(ct);
invoice.CustomerSnapshotTaxNo = await _customers.Query().Where(x => x.CustomerId == request.CustomerId).Select(x => x.TaxRegistrationNo).FirstAsync(ct);
@@ -201,5 +205,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
}
@@ -31,7 +31,7 @@ public sealed class SalesMappingService : ISalesMappingService
public SalesInvoiceDto MapInvoice(SalesInvoice invoice)
=> new(
invoice.SalesInvoiceId, invoice.InvoiceNo, invoice.InvoiceDate, invoice.CustomerId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId,
invoice.CustomerSnapshotName, invoice.CustomerSnapshotTaxNo, invoice.WarehouseId, invoice.BranchId,
invoice.InvoiceType, invoice.Status, invoice.CreatedBy, invoice.CreatedAt, invoice.UpdatedAt,
invoice.GlJournalNo, invoice.GlPostedAt,
MapInvoiceTotals(invoice),
+10 -6
View File
@@ -34,11 +34,13 @@ public sealed class TransferService : ITransferService
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
@@ -46,6 +48,7 @@ public sealed class TransferService : ITransferService
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -62,15 +65,14 @@ public sealed class TransferService : ITransferService
if (status is not null) q = q.Where(t => t.Status == status);
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
// A transfer touches two branches when it crosses one — show it on either side
// rather than only the branch that happens to hold the source warehouse.
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
// Warehouses are shared across branches — the transfer carries its own owning BranchId.
if (branchId is not null) q = q.Where(t => t.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(t => t.TransferId)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => new TransferSummaryDto(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status,
t.CreatedBy, t.CreatedAt, t.Lines.Count))
.ToListAsync(ct);
@@ -90,6 +92,7 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
@@ -103,6 +106,7 @@ public sealed class TransferService : ITransferService
DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId,
BranchId = branchId,
Status = TransferStatus.Draft,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
@@ -227,7 +231,7 @@ public sealed class TransferService : ITransferService
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, t.CreatedBy, t.CreatedAt,
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}
@@ -18,27 +18,25 @@ public sealed class UserManagementService : IUserManagementService
private readonly IRepository<User> _users;
private readonly IRepository<Role> _roles;
private readonly IRepository<Employee> _employees;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_employees = employees;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_employeeService = employeeService;
_uow = uow;
}
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
var authUserId = Guid.NewGuid();
// Source of truth: AuthHex creates the credential + emails it (registerUser,
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
await _authUsers.RegisterAsync(new RegisterRequest
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
// AuthHex client directly rather than IAuthUserService.RegisterAsync — that
// wrapper is shaped for self-registration (AuthController.Register) and demands
// a session (access/refresh tokens) in the response, which makes no sense here:
// an admin creating an account for someone else never logs in as them, so
// AuthHex's registerUser response for this case carries no tokens to demand.
await _authHex.RegisterAsync(new RegisterRequest
{
UserId = authUserId,
RoleId = role.AuthRoleId,
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
user.Status = status;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+2 -1
View File
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
{
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
}
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)
@@ -47,6 +47,7 @@ public static class ErrorCodes
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
public const string TemplateInUse = "TEMPLATE_IN_USE";
@@ -1,14 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
}
}
@@ -0,0 +1,21 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Port=5432;Database=ERPCore;Username=postgres;Password=CHANGE_ME"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "ask-the-team-for-the-dev-key"
},
"Seed": {
"DemoData": true
}
}
@@ -1,11 +0,0 @@
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Jwt": {
"SigningKey": ""
},
"AuthHex": {
"BaseUrl": ""
}
}
+7 -4
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
"DefaultConnection": ""
},
"Auth": {
"Issuer": "AuthHex",
@@ -16,15 +16,18 @@
"RequiredRoleCode": ""
},
"AuthHex": {
"BaseUrl": "http://localhost:5602"
"BaseUrl": ""
},
"FileStorage": {
"RootPath": "App_Data/hr-documents",
"MaxSizeBytes": 10485760
},
"LogFile": {
"Path": "logs/erpcore-.log"
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
"BaseUrl": "",
"ApiKey": ""
},
"Grn": {
"GlInventoryAccountCode": "1100",
+28
View File
@@ -4,6 +4,34 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
## Deployment readiness + CI/CD (2026-09-14) — `docs/40-DEPLOYMENT.md`
- [x] **Secrets out of git.** `appsettings.Development.json` untracked (template: `appsettings.Development.json.example`); `appsettings.Production.json` deleted (its `Jwt:SigningKey` was unused); base `appsettings.json` now blanks the connection string, `AuthHex:BaseUrl` and `GeneralLedgerService:BaseUrl/ApiKey`; `Backend/smoke/smoke_common.py` has no default password (`ERP_SMOKE_PASSWORD`). Values committed earlier still need rotating (`docs/02-SECURITY.md` AR-06).
- [x] **Fail-fast config check** (`Program.cs`): outside Development, startup throws one `InvalidOperationException` naming every blank required key. Verified: `ASPNETCORE_ENVIRONMENT=Production` with no env vars → lists the four blank keys and exits. In Development (where that check is skipped) the `AuthHex`/`GeneralLedgerService` HTTP-client registrations now treat a **blank** `BaseUrl` as missing — they only caught null, so the newly blank base values would have surfaced as an unnamed `UriFormatException` — and `GeneralLedgerClient` returns `GL_SERVICE_UNAVAILABLE` naming `GeneralLedgerService:ApiKey` instead of forwarding GL's 401 for an empty `X-Api-Key`.
- [x] **`LogFile:Path`** drives the Serilog file sink (default unchanged); **Swagger** is now off only in Production (test/Staging keeps it; Apache does not proxy it); **`UseForwardedHeaders`** for X-Forwarded-For/Proto from the co-located Apache.
- [x] **Seeder split** — `DataSeeder.SeedAsync(db, includeDemoData)`: reference data (reason codes, item types, product/branch/notification config, sales document counters) always; demo data (MAIN/SHOP warehouses, PCS/BOX UOMs, General Goods, items, stock layers, customers, bundles, sample docs) only with `Seed:DemoData` — default on in Development, `Seed__DemoData=true` on test, never on release. **Deviation from the plan:** no `Seed` key in base `appsettings.json`, because it would override the Development default.
- [x] **`ERPCore.Tests` added to `ERPCore.sln`.** It was outside the solution and had **stopped compiling** (`TestCurrentUser` lacked `ICurrentUser.RoleCode`/`IsAdmin`, added in `f664961`) — fixed, so CI can run it.
- [x] `dotnet ef migrations has-pending-model-changes` verified with a blank connection string (what CI has) → "No changes have been made to the model since the last migration."
- Pipeline assets: `.gitea/workflows/{ci,deploy}.yml`, `deploy/scripts/*.sh`, `deploy/{systemd,apache,env}/`. `detect.sh` exercised locally against a scratch state dir (first deploy / no-op / frontend-only / forced backend / rollback / non-deployable branch — all as designed); `migration-scan.sh`'s awk ignores `Down()` and flags real `Up()` drops (e.g. `20260831060800_AddPurchaseInvoices`); shellcheck clean at `--severity=warning`. **Not yet exercised:** anything needing Docker or the VPS (container builds, systemd, Apache, the Gitea runner).
- [x] **2026-09-15 — switched to a production-only, Fitness-style deployment** (only `erp.hexdive.com` exists; `docs/40-DEPLOYMENT.md` "Active").
- A PR merged into `production` runs one job:
- detect which side changed (tree hashes, `base.sha` vs merge commit);
- host `dotnet publish`;
- `pg_dump` when migrations changed (30-day retention);
- guard that uploads and logs are outside the publish folder;
- stop `erpcore.service`, wipe and copy `/home/erp/core/publish`, `chown erp`, start;
- `deploy/scripts/wait-healthy.sh`, which fails fast on a crash and allows up to 300 s for migrations.
- No auto-rollback.
- The two-environment pipeline, test Apache vhost, `erpcore@.service` and the hash/release/E2E/prune scripts moved to `deploy/on-hold/` (README explains restoring); the `test` branch in `common.sh` is commented out.
- Verified locally: both workflows are valid YAML; shellcheck is clean on the active scripts and on every inline `run:` block; the detect step gives the right flags on real merges (`8835ba9` frontend-only, `ee9fa40` backend+migrations, `8555702` neither, `eb36e0a` both), on a manual run and when the base commit is unavailable (both sides); the guard step rejects paths inside the publish folder (including the folder itself and the log path) and accepts `..`-escapes, sibling prefixes and quoted values.
- **Not exercised:** the real Gitea run, `erpcore.service` under `/home` (SELinux), `pg_dump` via `runuser`.
- [x] **2026-09-15 — `ci.yml` + `deploy.yml` merged into one `.gitea/workflows/ci-cd.yml`.**
- Check jobs run on PR open / update / reopen into `Dev` or `production`; `Build & Deploy` runs only when a PR is merged into `production` (the job checks `pull_request.base.ref` itself, so a merge into `Dev` deploys nothing) or on a manual run.
- One concurrency group for the file, never cancelled: a PR push can't interrupt a deploy, but superseded check runs now queue.
- The deploy's env vars moved from workflow level to the deploy job, so they don't leak into the check jobs.
- Verified locally: valid YAML, and every job `if` evaluated against six event shapes (PR opened into Dev, updated into production, merged into Dev, merged into production, closed unmerged, manual run) gives the intended jobs.
- Once branch protection is set up, the required check appears as `CI/CD - ERP Core / ci-ok`.
## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below
The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page
+6 -2
View File
@@ -38,7 +38,8 @@ DEFAULTS = {
"api": "http://localhost:5224/api/v1",
"auth": "http://localhost:5602",
"user": "admin@gmail.com",
"password": "Naveen@99",
# Deliberately no default: credentials never live in the repo (docs/40-DEPLOYMENT.md §5).
"password": None,
}
@@ -46,7 +47,10 @@ def parse_args(description: str) -> argparse.Namespace:
p = argparse.ArgumentParser(description=description)
for key, default in DEFAULTS.items():
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
return p.parse_args()
args = p.parse_args()
if not args.password:
p.error("no password given: set ERP_SMOKE_PASSWORD or pass --password")
return args
class Response:

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