Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b87a6e421 | |||
| 2a7a44cc27 | |||
| c7cf63e7de | |||
| d1044e4728 | |||
| d6f973202d | |||
| 1d5a5cf68e | |||
| 8e472ac996 | |||
| 666979cf22 | |||
| eb36e0acfa | |||
| 0ff39e9e58 | |||
| cbb610fc8b | |||
| afa9928dcc | |||
| 99aa395fb1 | |||
| cb41d503c4 | |||
| 629f3d1f44 | |||
| b4993b202d |
+1
Submodule .claude/worktrees/agent-a98805dc618e0f67c added at b4993b202d
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -16,9 +15,8 @@ public sealed class UomsController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, status, ct));
|
||||
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _uoms.ListAsync(query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||
@@ -28,14 +26,4 @@ public sealed class UomsController : ApiControllerBase
|
||||
var dto = await _uoms.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||
[HttpPatch("{uomId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> SetStatus(int uomId, [FromBody] UpdateUomStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
await _uoms.SetStatusAsync(uomId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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)]
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
@@ -12,8 +10,4 @@ public class Uom
|
||||
{
|
||||
public int UomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Uoms;
|
||||
|
||||
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
public sealed record UomDto(int UomId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
public sealed record UomDto(int UomId, string Name);
|
||||
|
||||
public sealed class CreateUomRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class UpdateUomStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -14,13 +13,5 @@ public sealed class UomConfiguration : IEntityTypeConfiguration<Uom>
|
||||
|
||||
builder.Property(u => u.Name).IsRequired().HasMaxLength(50);
|
||||
builder.HasIndex(u => u.Name).IsUnique();
|
||||
|
||||
builder.Property(u => u.Status)
|
||||
.HasConversion<string>().HasMaxLength(20).IsRequired()
|
||||
.HasDefaultValue(EntityStatus.Active);
|
||||
|
||||
builder.Property(u => u.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(u => u.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
+67
-17
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
[DbContext(typeof(ErpDbContext))]
|
||||
[Migration("20260914061430_AddUomLifecycleColumnsLocal")]
|
||||
partial class AddUomLifecycleColumnsLocal
|
||||
[Migration("20260908101847_AddBranchIdToDocuments")]
|
||||
partial class AddBranchIdToDocuments
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -1450,6 +1450,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");
|
||||
|
||||
@@ -1496,6 +1499,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("GrnId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4190,6 +4195,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");
|
||||
|
||||
@@ -4282,6 +4290,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("SalesInvoiceId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatorUserId");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
@@ -4862,6 +4872,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");
|
||||
|
||||
@@ -4899,6 +4912,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("AdjustmentId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -4961,6 +4976,9 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CountId"));
|
||||
|
||||
b.Property<int>("BranchId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CountType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -4993,6 +5011,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("CountId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DocNo")
|
||||
@@ -5185,6 +5205,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");
|
||||
|
||||
@@ -5215,6 +5238,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("TransferId");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CreatedBy");
|
||||
|
||||
b.HasIndex("DestWarehouseId");
|
||||
@@ -5762,31 +5787,16 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UomId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("uoms", (string)null);
|
||||
});
|
||||
|
||||
@@ -6398,6 +6408,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")
|
||||
@@ -6426,6 +6442,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
@@ -7160,6 +7178,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");
|
||||
@@ -7176,6 +7200,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -7441,6 +7467,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")
|
||||
@@ -7459,6 +7491,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
@@ -7502,6 +7536,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")
|
||||
@@ -7514,6 +7554,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
@@ -7621,6 +7663,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")
|
||||
@@ -7639,6 +7687,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("DestWarehouse");
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8005
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUomLifecycleColumnsLocal : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAt",
|
||||
table: "uoms",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Status",
|
||||
table: "uoms",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Active");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAt",
|
||||
table: "uoms",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_uoms_Status",
|
||||
table: "uoms",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_uoms_Status",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "uoms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAt",
|
||||
table: "uoms");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5759,31 +6000,16 @@ namespace ERPCore.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UomId"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Active");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UomId");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("uoms", (string)null);
|
||||
});
|
||||
|
||||
@@ -6395,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")
|
||||
@@ -6423,6 +6655,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("PurchaseInvoice");
|
||||
@@ -6600,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")
|
||||
@@ -7157,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");
|
||||
@@ -7173,6 +7424,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -7438,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")
|
||||
@@ -7456,6 +7715,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("ReasonCode");
|
||||
@@ -7499,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")
|
||||
@@ -7511,6 +7778,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
@@ -7618,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")
|
||||
@@ -7636,6 +7911,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Creator");
|
||||
|
||||
b.Navigation("DestWarehouse");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
|
||||
@@ -7,7 +6,6 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>UOM master business logic (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
public interface IUomService
|
||||
{
|
||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
|
||||
Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<UomDto> CreateAsync(CreateUomRequest request, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int uomId, EntityStatus status, 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 & 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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Uoms;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -21,7 +20,7 @@ public sealed class UomService : IUomService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<UomDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _uoms.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -29,12 +28,11 @@ public sealed class UomService : IUomService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(u => EF.Functions.ILike(u.Name, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(u => u.Status == status);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(u => u.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(u => new UomDto(u.UomId, u.Name, u.Status, u.CreatedAt, u.UpdatedAt))
|
||||
.Select(u => new UomDto(u.UomId, u.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<UomDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -46,20 +44,10 @@ public sealed class UomService : IUomService
|
||||
if (await _uoms.Query().AnyAsync(u => u.Name == name, ct))
|
||||
throw new ConflictException($"A UOM named '{name}' already exists.");
|
||||
|
||||
var uom = new Uom { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
var uom = new Uom { Name = name };
|
||||
await _uoms.AddAsync(uom, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new UomDto(uom.UomId, uom.Name, uom.Status, uom.CreatedAt, uom.UpdatedAt);
|
||||
}
|
||||
|
||||
public async Task SetStatusAsync(int uomId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var uom = await _uoms.GetByIdAsync(uomId, ct)
|
||||
?? throw new NotFoundException($"UOM {uomId} was not found.");
|
||||
|
||||
uom.Status = status;
|
||||
uom.UpdatedAt = DateTime.UtcNow;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new UomDto(uom.UomId, uom.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user