45 Commits

Author SHA1 Message Date
5f73924572 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 17:48:22 +01:00
8edc4af645 fix(back-end): shift model
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 24s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 17:47:41 +01:00
e1409d218b fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:46:00 +01:00
0c92f8b394 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:36:53 +01:00
66de93a315 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 17:32:29 +01:00
b337db03c4 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:14:25 +01:00
8c6c1e10b3 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:19:18 +01:00
eac3006512 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 38s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 16:16:03 +01:00
b26b582baf fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 33s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:13:42 +01:00
875c6ffd2d fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 32s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:09:36 +01:00
579ac3fcb6 fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:01:01 +01:00
efa1371ffa fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:56:55 +01:00
ab7f263aca fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:55:16 +01:00
49bae8e186 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:51:04 +01:00
e2872c730c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:40:16 +01:00
86266b31ee fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 1m23s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:28:16 +01:00
5d0fb5fe6d fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 25s
Build, Test and Deploy / deploy (push) Has been skipped
Build, Test and Deploy / build-and-push (push) Has been skipped
2026-02-16 15:25:15 +01:00
91af8f4f9c fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 27s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 15:23:02 +01:00
a96c28fb39 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:07:21 +01:00
9b24ca529c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:01:46 +01:00
6216d9a723 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:54:57 +01:00
4aa3f6adf1 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:52:14 +01:00
7baad738f5 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:48:46 +01:00
9feceb9b3c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 14:36:26 +01:00
304ed942b8 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:34:10 +01:00
881bd87392 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 14:28:40 +01:00
3a5e4e3427 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
Build, Test and Deploy / test-backend (push) Successful in 1m20s
2026-02-16 14:20:31 +01:00
8c82470401 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:14:47 +01:00
ef6a5278a7 fix(back-end): revert changes in uplad file
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:05:43 +01:00
bb276b6504 fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 13:57:44 +01:00
e351f2c05f fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m23s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 11:44:05 +01:00
165e12f216 fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 33s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-14 19:10:02 +01:00
475bfcc6fb fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-14 16:53:21 +01:00
becb15da73 fix invoice and support costs
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 49s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-14 16:47:49 +01:00
4d559901eb fix(front-end): cost displayed in order
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:22:58 +01:00
06a036810a fix(back-end): forse risolviamo il problema
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:19:26 +01:00
0b29aebfcf feat(back-end): invoice rotto ma pusshamolo lo stesso
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 54s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-13 18:14:52 +01:00
961109b04c feat: default pla filaments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m31s
Build, Test and Deploy / build-and-push (push) Successful in 16s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:30:00 +01:00
b5bd68ed10 fix(back-end): back-end fix mantain settings
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-13 16:21:28 +01:00
56fb504062 fix(front-end): error handling for session
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:08:04 +01:00
f165d191be feat(back-end):improvement
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Failing after 37s
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-13 16:03:44 +01:00
e1d9823b51 feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-12 22:11:47 +01:00
f829ccef4a feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m17s
Build, Test and Deploy / build-and-push (push) Successful in 48s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 22:07:13 +01:00
59e881c3f4 feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:59:48 +01:00
f5aa0f298e feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:50:42 +01:00
314 changed files with 6089 additions and 30817 deletions

View File

@@ -1,11 +1,11 @@
name: Build and Deploy name: Build, Test and Deploy
on: on:
push: push:
branches: [main, int, dev] branches: [main, int, dev]
concurrency: concurrency:
group: print-calculator-deploy-${{ gitea.ref }} group: print-calculator-${{ gitea.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -18,9 +18,8 @@ jobs:
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: "21" java-version: '21'
distribution: "temurin" distribution: 'temurin'
cache: gradle
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |
@@ -28,55 +27,8 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew test ./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Resolve Chrome binary
shell: bash
run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update
apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless)
shell: bash
env:
CI: "true"
run: |
cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push: build-and-push:
needs: [test-backend, test-frontend] needs: test-backend
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -154,33 +106,32 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
chmod 700 ~/.ssh chmod 700 ~/.ssh
# 1) Prende il secret base64 e rimuove spazi/newline/CR
printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64 printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64
# 2) (debug sicuro) stampa solo la lunghezza della base64
echo "b64_len=$(wc -c < /tmp/key.b64)" echo "b64_len=$(wc -c < /tmp/key.b64)"
# 3) Decodifica in chiave privata
base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519 base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519
# 4) Rimuove eventuali CRLF dentro la chiave (se proviene da Windows)
tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean
mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519 mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Write env and compose to server - name: Write env to server
shell: bash shell: bash
run: | run: |
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then # 1. Start with the static env file content
DEPLOY_TAG="prod"
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
DEPLOY_TAG="int"
else
DEPLOY_TAG="dev"
fi
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 2. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}" DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -195,28 +146,25 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi fi
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \ # 3. Append DB credentials
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \ # 4. Debug: print content (for debug purposes)
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
ADMIN_TTL="${{ secrets.ADMIN_SESSION_TTL_MINUTES }}"
ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true grep -v "PASSWORD" /tmp/full_env.env || true
# 5. Send to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
- name: Trigger deploy on Unraid (forced command key) - name: Trigger deploy on Unraid (forced command key)
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
# e assicuriamoci che l'input sia pulito
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}" ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"

View File

@@ -1,185 +0,0 @@
name: PR Checks
on:
pull_request:
branches: [main, int, dev]
concurrency:
group: print-calculator-pr-${{ gitea.ref }}
cancel-in-progress: true
jobs:
prettier-autofix:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Apply formatting with Prettier
shell: bash
run: |
npx --yes prettier@3.6.2 --write \
"frontend/src/**/*.{ts,html,scss,css,json}" \
".gitea/workflows/*.{yml,yaml}"
- name: Commit and push formatting changes
shell: bash
run: |
if git diff --quiet; then
echo "No formatting changes to commit."
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends jq
fi
EVENT_FILE="${GITHUB_EVENT_PATH:-}"
if [[ -z "$EVENT_FILE" || ! -f "$EVENT_FILE" ]]; then
echo "Event payload not found, skipping auto-push."
exit 0
fi
HEAD_REPO="$(jq -r '.pull_request.head.repo.full_name // empty' "$EVENT_FILE")"
BASE_REPO="$(jq -r '.repository.full_name // empty' "$EVENT_FILE")"
PR_BRANCH="$(jq -r '.pull_request.head.ref // empty' "$EVENT_FILE")"
if [[ -z "$PR_BRANCH" ]]; then
echo "PR branch not found in event payload, skipping auto-push."
exit 0
fi
if [[ -n "$HEAD_REPO" && -n "$BASE_REPO" && "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "PR from fork ($HEAD_REPO), skipping auto-push."
exit 0
fi
git config user.name "printcalc-ci"
git config user.email "ci@printcalculator.local"
git add frontend/src .gitea/workflows
git commit -m "style: apply prettier formatting"
git push origin "HEAD:${PR_BRANCH}"
security-sast:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Python and Semgrep
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip
python3 -m pip install --upgrade pip
python3 -m pip install semgrep
- name: Run Semgrep (SAST)
shell: bash
run: |
semgrep --version
semgrep --config auto --error \
--exclude frontend/node_modules \
--exclude backend/build \
backend/src frontend/src
- name: Install Gitleaks
shell: bash
run: |
set -euo pipefail
VERSION="8.24.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Run Gitleaks (secrets scan)
shell: bash
run: |
set +e
gitleaks detect --source . --no-git --redact --exit-code 1 \
--report-format json --report-path /tmp/gitleaks-report.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Gitleaks findings:"
cat /tmp/gitleaks-report.json
fi
exit $rc
test-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle
run: |
cd backend
chmod +x gradlew
./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Resolve Chrome binary
shell: bash
run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update
apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless)
shell: bash
env:
CI: "true"
run: |
cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

8
.gitignore vendored
View File

@@ -41,11 +41,3 @@ target/
build/ build/
.gradle/ .gradle/
.mvn/ .mvn/
./storage_orders
./storage_quotes
storage_orders
storage_quotes
# Qodana local reports/artifacts
backend/.qodana/

View File

@@ -10,58 +10,36 @@ RUN ./gradlew bootJar -x test --no-daemon
# Stage 2: Runtime Environment # Stage 2: Runtime Environment
FROM eclipse-temurin:21-jre-jammy FROM eclipse-temurin:21-jre-jammy
ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL
# Install system dependencies for OrcaSlicer (same as before) # Install system dependencies for OrcaSlicer
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget \ wget \
assimp-utils \ p7zip-full \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37 \ libwebkit2gtk-4.0-37 \
libx11-xcb1 \
libxcb-dri3-0 \
libxtst6 \
libnss3 \
libatk-bridge2.0-0 \
libxss1 \
libasound2 \
libgbm1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer # Install OrcaSlicer
WORKDIR /opt WORKDIR /opt
RUN set -eux; \ RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \
ORCA_URL="${ORCA_DOWNLOAD_URL:-}"; \ && 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \
if [ -n "${ORCA_URL}" ]; then \
wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage; \
else \
CANDIDATES="\
https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage"; \
ok=0; \
for url in $CANDIDATES; do \
if wget -q --spider "$url"; then \
echo "Using OrcaSlicer URL: $url"; \
wget -q "$url" -O OrcaSlicer.AppImage; \
ok=1; \
break; \
fi; \
done; \
if [ "$ok" -ne 1 ]; then \
echo "Failed to find OrcaSlicer AppImage for version ${ORCA_VERSION}" >&2; \
echo "Tried URLs:" >&2; \
for url in $CANDIDATES; do echo " - $url" >&2; done; \
exit 1; \
fi; \
fi \
&& chmod +x OrcaSlicer.AppImage \
&& rm -rf /opt/orcaslicer /opt/squashfs-root \
&& ./OrcaSlicer.AppImage --appimage-extract >/dev/null \
&& mv /opt/squashfs-root /opt/orcaslicer \
&& chmod -R +x /opt/orcaslicer \ && chmod -R +x /opt/orcaslicer \
&& rm OrcaSlicer.AppImage && rm OrcaSlicer.AppImage
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}" ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -24,15 +24,11 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'xyz.capybara:clamav-client:2.1.2' implementation 'xyz.capybara:clamav-client:2.1.2'
implementation 'org.springframework.boot:spring-boot-starter-actuator' runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
@@ -41,16 +37,6 @@ dependencies {
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37' implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
implementation 'org.lwjgl:lwjgl'
implementation 'org.lwjgl:lwjgl-assimp'
runtimeOnly 'org.lwjgl:lwjgl::natives-linux'
runtimeOnly 'org.lwjgl:lwjgl::natives-macos'
runtimeOnly 'org.lwjgl:lwjgl::natives-macos-arm64'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-linux'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos-arm64'

View File

@@ -3,23 +3,13 @@ echo "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---"
env
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Exec java with explicit properties from env
# This allows compatibility with different docker-compose configurations exec java -jar app.jar \
FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}" --spring.datasource.url="${DB_URL}" \
FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}" --spring.datasource.username="${DB_USERNAME}" \
FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}" --spring.datasource.password="${DB_PASSWORD}"
if [ -n "$FINAL_DB_URL" ]; then
echo "Using database URL: $FINAL_DB_URL"
exec java -jar app.jar \
--spring.datasource.url="${FINAL_DB_URL}" \
--spring.datasource.username="${FINAL_DB_USER}" \
--spring.datasource.password="${FINAL_DB_PASS}"
else
echo "No database URL specified in environment, relying on application.properties defaults."
exec java -jar app.jar
fi

File diff suppressed because one or more lines are too long

View File

@@ -139,4 +139,4 @@
"layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change",
"change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A", "change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A",
"machine_pause_gcode": "M400 U1" "machine_pause_gcode": "M400 U1"
} }

View File

@@ -1,48 +0,0 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: "21" #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-jvm:2025.3

View File

@@ -2,15 +2,13 @@ package com.printcalculator;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class}) @SpringBootApplication
@EnableTransactionManagement @EnableTransactionManagement
@EnableScheduling @EnableScheduling
@EnableAsync
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -1,47 +0,0 @@
package com.printcalculator.config;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").denyAll()
.requestMatchers("/api/admin/auth/login").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().permitAll()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
}))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -1,21 +1,21 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.service.request.CustomQuoteRequestControllerService; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import jakarta.validation.Valid; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -23,25 +23,98 @@ import java.util.UUID;
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private final CustomQuoteRequestControllerService customQuoteRequestControllerService; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.StorageService storageService;
public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) { public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
this.customQuoteRequestControllerService = customQuoteRequestControllerService; CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.StorageService storageService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.storageService = storageService;
} }
// 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest( public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@Valid @RequestPart("request") QuoteRequestDto requestDto, @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files @RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException { ) throws IOException {
return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files));
// 1. Create Request
CustomQuoteRequest request = new CustomQuoteRequest();
request.setRequestType(requestDto.getRequestType());
request.setCustomerType(requestDto.getCustomerType());
request.setEmail(requestDto.getEmail());
request.setPhone(requestDto.getPhone());
request.setName(requestDto.getName());
request.setCompanyName(requestDto.getCompanyName());
request.setContactPerson(requestDto.getContactPerson());
request.setMessage(requestDto.getMessage());
request.setStatus("PENDING");
request.setCreatedAt(OffsetDateTime.now());
request.setUpdatedAt(OffsetDateTime.now());
request = requestRepo.save(request);
// 2. Handle Attachments
if (files != null && !files.isEmpty()) {
if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed.");
}
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename());
attachment.setMimeType(file.getContentType());
attachment.setFileSizeBytes(file.getSize());
attachment.setCreatedAt(OffsetDateTime.now());
// Generate path
UUID fileUuid = UUID.randomUUID();
String ext = getExtension(file.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext;
// Note: We don't have attachment ID yet.
// We'll save attachment first to get ID.
attachment.setStoredFilename(storedFilename);
attachment.setStoredRelativePath("PENDING");
attachment = attachmentRepo.save(attachment);
String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename;
attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment);
// Save file to disk via StorageService
storageService.store(file, Paths.get(relativePath));
}
}
return ResponseEntity.ok(request);
} }
// 2. Get Request
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) { public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) {
return customQuoteRequestControllerService.getCustomQuoteRequest(id) return requestRepo.findById(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
// Helper
private String getExtension(String filename) {
if (filename == null) return "dat";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
}
return "dat";
}
} }

View File

@@ -3,33 +3,18 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse; import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.*; // This line replaces specific entity imports
import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.ProfileManager;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -37,326 +22,105 @@ public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
private final PrinterMachineProfileRepository printerMachineProfileRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
private final ProfileManager profileManager;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
NozzleOptionRepository nozzleRepo, LayerHeightOptionRepository layerHeightRepo,
PrinterMachineRepository printerMachineRepo, NozzleOptionRepository nozzleRepo) {
PrinterMachineProfileRepository printerMachineProfileRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver,
ProfileManager profileManager,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
this.printerMachineProfileRepo = printerMachineProfileRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.profileManager = profileManager;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
@Transactional(readOnly = true) public ResponseEntity<OptionsResponse> getOptions() {
public ResponseEntity<OptionsResponse> getOptions( // 1. Materials & Variants
@RequestParam(value = "printerMachineId", required = false) Long printerMachineId,
@RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter
) {
List<FilamentMaterialType> types = materialRepo.findAll(); List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findByIsActiveTrue().stream() List<FilamentVariant> allVariants = variantRepo.findAll();
.sorted(Comparator
.comparing((FilamentVariant v) -> safeMaterialCode(v.getFilamentMaterialType()), String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> safeString(v.getVariantDisplayName()), String.CASE_INSENSITIVE_ORDER))
.toList();
Set<Long> compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter);
List<OptionsResponse.MaterialOption> materialOptions = types.stream() List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER))
.map(type -> { .map(type -> {
if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) {
return null;
}
List<OptionsResponse.VariantOption> variants = allVariants.stream() List<OptionsResponse.VariantOption> variants = allVariants.stream()
.filter(v -> v.getFilamentMaterialType() != null .filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
&& v.getFilamentMaterialType().getId().equals(type.getId()))
.map(v -> new OptionsResponse.VariantOption( .map(v -> new OptionsResponse.VariantOption(
v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
resolveHexColor(v), getColorHex(v.getColorName()), // Need helper or store hex in DB
v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getStockSpools().doubleValue() <= 0
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
toStockFilamentGrams(v),
v.getStockSpools() == null || v.getStockSpools().doubleValue() <= 0
)) ))
.collect(Collectors.toList()); .collect(Collectors.toList());
if (variants.isEmpty()) { // Only include material if it has active variants
return null; if (variants.isEmpty()) return null;
}
return new OptionsResponse.MaterialOption( return new OptionsResponse.MaterialOption(
type.getMaterialCode(), type.getMaterialCode(),
type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"), type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
variants variants
); );
}) })
.filter(m -> m != null) .filter(m -> m != null)
.toList(); .collect(Collectors.toList());
// Sort: PLA first, then PETG, then others alphabetically
materialOptions.sort((a, b) -> {
String codeA = a.code();
String codeB = b.code();
if (codeA.equals("pla_basic")) return -1;
if (codeB.equals("pla_basic")) return 1;
if (codeA.equals("petg_basic")) return -1;
if (codeB.equals("petg_basic")) return 1;
return codeA.compareTo(codeB);
});
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of( List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"), new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"), new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition") new OptionsResponse.QualityOption("extra_fine", "High Definition")
); );
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of( List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"), new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
PrinterMachine targetMachine = resolveMachine(printerMachineId); // 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
Set<BigDecimal> supportedMachineNozzles = targetMachine != null .filter(l -> l.getIsActive())
? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream() .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(PrinterMachineProfile::getNozzleDiameterMm) .map(l -> new OptionsResponse.LayerHeightOptionDTO(
.filter(v -> v != null) l.getLayerHeightMm().doubleValue(),
.map(nozzleLayerHeightPolicyService::normalizeNozzle) String.format("%.2f mm", l.getLayerHeightMm())
.collect(Collectors.toCollection(LinkedHashSet::new)) ))
: Set.of(); .collect(Collectors.toList());
boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty();
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(n.getIsActive())) .filter(n -> n.getIsActive())
.filter(n -> {
if (!restrictNozzlesByMachineProfile) {
return true;
}
BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(n.getNozzleDiameterMm());
return normalized != null && supportedMachineNozzles.contains(normalized);
})
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO( .map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(), n.getNozzleDiameterMm().doubleValue(),
String.format("%.1f mm%s", n.getNozzleDiameterMm(), String.format("%.1f mm%s", n.getNozzleDiameterMm(),
n.getExtraNozzleChangeFeeChf().doubleValue() > 0 n.getExtraNozzleChangeFeeChf().doubleValue() > 0
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)") : " (Standard)")
)) ))
.toList(); .collect(Collectors.toList());
Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
Set<BigDecimal> visibleNozzlesFromOptions = nozzles.stream()
.map(OptionsResponse.NozzleOptionDTO::value)
.map(BigDecimal::valueOf)
.map(nozzleLayerHeightPolicyService::normalizeNozzle)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<BigDecimal, List<BigDecimal>> effectiveRulesByNozzle = new LinkedHashMap<>();
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
List<BigDecimal> compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle);
List<BigDecimal> effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers);
if (!effective.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, effective);
}
}
if (effectiveRulesByNozzle.isEmpty()) {
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
if (!policyLayers.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, policyLayers);
}
}
}
Set<BigDecimal> visibleNozzles = new LinkedHashSet<>(effectiveRulesByNozzle.keySet());
nozzles = nozzles.stream()
.filter(option -> {
BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(
BigDecimal.valueOf(option.value())
);
return normalized != null && visibleNozzles.contains(normalized);
})
.toList();
BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) {
selectedNozzle = visibleNozzles.iterator().next();
}
List<OptionsResponse.LayerHeightOptionDTO> layers = toLayerDtos(
effectiveRulesByNozzle.getOrDefault(selectedNozzle, List.of())
);
if (layers.isEmpty()) {
if (!visibleNozzles.isEmpty()) {
BigDecimal fallbackNozzle = visibleNozzles.iterator().next();
layers = toLayerDtos(effectiveRulesByNozzle.getOrDefault(fallbackNozzle, List.of()));
}
if (layers.isEmpty()) {
layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
}
}
List<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream()
.map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
entry.getKey().doubleValue(),
toLayerDtos(entry.getValue())
))
.toList();
return ResponseEntity.ok(new OptionsResponse(
materialOptions,
qualities,
patterns,
layers,
nozzles,
layerHeightsByNozzle
));
} }
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { // Temporary helper until we add hex to DB
PrinterMachine machine = resolveMachine(printerMachineId);
if (machine == null) {
return Set.of();
}
BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle)
.orElse(null);
if (machineProfile == null) {
return Set.of();
}
List<MaterialOrcaProfileMap> maps = materialOrcaMapRepo.findByPrinterMachineProfileAndIsActiveTrue(machineProfile);
return maps.stream()
.map(MaterialOrcaProfileMap::getFilamentMaterialType)
.filter(m -> m != null && m.getId() != null)
.map(FilamentMaterialType::getId)
.collect(Collectors.toSet());
}
private PrinterMachine resolveMachine(Long printerMachineId) {
PrinterMachine machine = null;
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
return machine;
}
private List<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) {
return layers.stream()
.sorted(Comparator.naturalOrder())
.map(layer -> new OptionsResponse.LayerHeightOptionDTO(
layer.doubleValue(),
String.format("%.2f mm", layer)
))
.toList();
}
private List<BigDecimal> resolveCompatibleProcessLayers(PrinterMachine machine, BigDecimal nozzle) {
if (machine == null || nozzle == null) {
return List.of();
}
PrinterMachineProfile profile = orcaProfileResolver.resolveMachineProfile(machine, nozzle).orElse(null);
if (profile == null || profile.getOrcaMachineProfileName() == null) {
return List.of();
}
return profileManager.findCompatibleProcessLayers(profile.getOrcaMachineProfileName());
}
private List<BigDecimal> mergePolicyAndProcessLayers(List<BigDecimal> policyLayers,
List<BigDecimal> processLayers) {
if ((processLayers == null || processLayers.isEmpty())
&& (policyLayers == null || policyLayers.isEmpty())) {
return List.of();
}
if (processLayers == null || processLayers.isEmpty()) {
return policyLayers != null ? policyLayers : List.of();
}
if (policyLayers == null || policyLayers.isEmpty()) {
return processLayers;
}
Set<BigDecimal> allowedByPolicy = policyLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<BigDecimal> intersection = processLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null && allowedByPolicy.contains(v))
.collect(Collectors.toCollection(ArrayList::new));
if (!intersection.isEmpty()) {
return intersection;
}
return processLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null)
.collect(Collectors.toCollection(ArrayList::new));
}
private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex();
}
return getColorHex(variant.getColorName());
}
private double toStockFilamentGrams(FilamentVariant variant) {
if (variant.getStockSpools() == null || variant.getSpoolNetKg() == null) {
return 0d;
}
return variant.getStockSpools()
.multiply(variant.getSpoolNetKg())
.multiply(BigDecimal.valueOf(1000))
.doubleValue();
}
private String safeMaterialCode(FilamentMaterialType type) {
if (type == null || type.getMaterialCode() == null) {
return "";
}
return type.getMaterialCode();
}
private String safeString(String value) {
return value == null ? "" : value;
}
// Temporary helper for legacy values where color hex is not yet set in DB
private String getColorHex(String colorName) { private String getColorHex(String colorName) {
if (colorName == null) {
return "#9e9e9e";
}
String lower = colorName.toLowerCase(); String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a"; if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5"; if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
@@ -370,6 +134,6 @@ public class OptionsController {
} }
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2"; if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d"; if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; return "#9e9e9e"; // Default grey
} }
} }

View File

@@ -1,101 +1,258 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.CreateOrderRequest; import com.printcalculator.dto.*;
import com.printcalculator.dto.OrderDto; import com.printcalculator.entity.*;
import com.printcalculator.service.order.OrderControllerService; import com.printcalculator.repository.*;
import jakarta.validation.Valid; import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.Map;
import java.util.HashMap;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private final OrderControllerService orderControllerService; private final OrderService orderService;
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final CustomerRepository customerRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public OrderController(OrderControllerService orderControllerService) {
this.orderControllerService = orderControllerService; public OrderController(OrderService orderService,
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.customerRepo = customerRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
} }
// 1. Create Order from Quote
@PostMapping("/from-quote/{quoteSessionId}") @PostMapping("/from-quote/{quoteSessionId}")
@Transactional @Transactional
public ResponseEntity<OrderDto> createOrderFromQuote( public ResponseEntity<OrderDto> createOrderFromQuote(
@PathVariable UUID quoteSessionId, @PathVariable UUID quoteSessionId,
@Valid @RequestBody CreateOrderRequest request @RequestBody com.printcalculator.dto.CreateOrderRequest request
) { ) {
return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request)); Order order = orderService.createOrderFromQuote(quoteSessionId, request);
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return ResponseEntity.ok(convertToDto(order, items));
} }
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<Void> uploadOrderItemFile( public ResponseEntity<Void> uploadOrderItemFile(
@PathVariable UUID orderId, @PathVariable UUID orderId,
@PathVariable UUID orderItemId, @PathVariable UUID orderItemId,
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
boolean uploaded = orderControllerService.uploadOrderItemFile(orderId, orderItemId, file);
if (!uploaded) { OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
if (!item.getOrder().getId().equals(orderId)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID().toString() + "." + ext;
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
item.setStoredRelativePath(relativePath);
item.setStoredFilename(storedFilename);
}
storageService.store(file, Paths.get(relativePath));
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/{orderId}") @GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) { public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return orderControllerService.getOrder(orderId) return orderRepo.findById(orderId)
.map(ResponseEntity::ok) .map(o -> {
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
return ResponseEntity.ok(convertToDto(o, items));
})
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@PostMapping("/{orderId}/payments/report")
@Transactional
public ResponseEntity<OrderDto> reportPayment(
@PathVariable UUID orderId,
@RequestBody Map<String, String> payload
) {
return orderControllerService.reportPayment(orderId, payload.get("method"))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return orderControllerService.getConfirmation(orderId);
}
@GetMapping("/{orderId}/invoice") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
return ResponseEntity.notFound().build(); Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private String getExtension(String filename) {
if (filename == null) return "stl";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
}
return "stl";
} }
@GetMapping("/{orderId}/twint") private OrderDto convertToDto(Order order, List<OrderItem> items) {
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) { OrderDto dto = new OrderDto();
return orderControllerService.getTwintPayment(orderId); dto.setId(order.getId());
dto.setStatus(order.getStatus());
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!order.getShippingSameAsBilling()) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).collect(Collectors.toList());
dto.setItems(itemDtos);
return dto;
} }
@GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
return orderControllerService.openTwintPayment(orderId);
}
@GetMapping("/{orderId}/twint/qr")
public ResponseEntity<byte[]> getTwintQr(
@PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size
) {
return orderControllerService.getTwintQr(orderId, size);
}
} }

View File

@@ -1,50 +1,47 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.StlService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.logging.Logger;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
public class QuoteController { public class QuoteController {
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
private final SlicerService slicerService; private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final ClamAVService clamAVService; private final ProfileManager profileManager;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
// Defaults (using aliases defined in ProfileManager) // Defaults (using aliases defined in ProfileManager)
private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard"; private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
ClamAVService clamAVService,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.clamAVService = clamAVService; this.profileManager = profileManager;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -58,7 +55,7 @@ public class QuoteController {
@RequestParam(value = "infill_pattern", required = false) String infillPattern, @RequestParam(value = "infill_pattern", required = false) String infillPattern,
@RequestParam(value = "layer_height", required = false) Double layerHeight, @RequestParam(value = "layer_height", required = false) Double layerHeight,
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter, @RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled @RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
) throws IOException { ) throws IOException {
// ... process selection logic ... // ... process selection logic ...
@@ -81,33 +78,24 @@ public class QuoteController {
if (infillPattern != null && !infillPattern.isEmpty()) { if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern); processOverrides.put("sparse_infill_pattern", infillPattern);
} }
BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (layerHeight != null) { if (layerHeight != null) {
BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight)); processOverrides.put("layer_height", String.valueOf(layerHeight));
if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Layer height " + normalizedLayer.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle)
);
}
processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString());
} }
if (supportEnabled != null) { if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0"); processOverrides.put("enable_support", supportEnabled ? "1" : "0");
if (supportEnabled) {
processOverrides.put("support_threshold_angle", "45");
}
} }
if (nozzleDiameter != null) { if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString()); machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter));
// Also need to ensure the printer profile is compatible or just override? // Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides. // Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile. // For now, we trust the override key works on the base profile.
} }
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides); return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
} }
@PostMapping("/calculate/stl") @PostMapping("/calculate/stl")
@@ -115,42 +103,91 @@ public class QuoteController {
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
// Legacy endpoint uses defaults // Legacy endpoint uses defaults
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null); return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
} }
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process, private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides, Map<String, String> machineOverrides,
Map<String, String> processOverrides) throws IOException { Map<String, String> processOverrides,
Double nozzleDiameter) throws IOException {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
// Scan for virus
clamAVService.scan(file.getInputStream());
// Fetch Default Active Machine // Fetch Default Active Machine
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new IOException("No active printer found in database")); .orElseThrow(() -> new IOException("No active printer found in database"));
// Save uploaded file temporarily // Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename()); Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
com.printcalculator.model.StlShiftResult shift = null;
try { try {
file.transferTo(tempInput.toFile()); file.transferTo(tempInput.toFile());
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity // Use profile from machine or fallback
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides); // Validate model size against machine volume
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
// Auto-center if needed
shift = stlService.shiftToFitIfNeeded(
tempInput.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup) // Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build();
} finally { } finally {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
} }
} }
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
} }

View File

@@ -1,148 +1,429 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.PrintSettingsDto; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.quote.QuoteSessionItemService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.quote.QuoteSessionResponseAssembler; import com.printcalculator.service.StlService;
import com.printcalculator.service.quote.QuoteStorageService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.core.io.Resource;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import org.springframework.core.io.UrlResource;
import java.util.logging.Logger;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
private final QuoteSessionRepository sessionRepo; private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo; private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final ProfileManager profileManager;
private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final QuoteSessionTotalsService quoteSessionTotalsService; private final com.printcalculator.service.StorageService storageService;
private final QuoteSessionItemService quoteSessionItemService;
private final QuoteStorageService quoteStorageService; // Defaults
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler; private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
ProfileManager profileManager,
PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
QuoteSessionTotalsService quoteSessionTotalsService, com.printcalculator.service.StorageService storageService) {
QuoteSessionItemService quoteSessionItemService,
QuoteStorageService quoteStorageService,
QuoteSessionResponseAssembler quoteSessionResponseAssembler) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.profileManager = profileManager;
this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.quoteSessionTotalsService = quoteSessionTotalsService; this.storageService = storageService;
this.quoteSessionItemService = quoteSessionItemService;
this.quoteStorageService = quoteStorageService;
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
} }
// 1. Start a new empty session
@PostMapping(value = "") @PostMapping(value = "")
@Transactional @Transactional
public ResponseEntity<QuoteSession> createSession() { public ResponseEntity<QuoteSession> createSession() {
QuoteSession session = new QuoteSession(); QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE"); session.setStatus("ACTIVE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
session.setMaterialCode("PLA"); // Default material/settings will be set when items are added or updated?
// For now set safe defaults
session.setMaterialCode("pla_basic");
session.setSupportsEnabled(false); session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now()); session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30)); session.setExpiresAt(OffsetDateTime.now().plusDays(30));
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy)); session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO);
session = sessionRepo.save(session); session = sessionRepo.save(session);
return ResponseEntity.ok(session); return ResponseEntity.ok(session);
} }
// 2. Add item to existing session
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<QuoteLineItem> addItemToExistingSession(@PathVariable UUID id, public ResponseEntity<QuoteLineItem> addItemToExistingSession(
@RequestPart("settings") PrintSettingsDto settings, @PathVariable UUID id,
@RequestPart("file") MultipartFile file) throws IOException { @RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings,
@RequestPart("file") MultipartFile file
) throws IOException {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));
QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings); QuoteLineItem item = addItemToSession(session, file, settings);
return ResponseEntity.ok(item); return ResponseEntity.ok(item);
} }
@PatchMapping("/line-items/{lineItemId}") // Helper to add item
@Transactional private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
public ResponseEntity<QuoteLineItem> updateLineItem(@PathVariable UUID lineItemId, if (file.isEmpty()) throw new IOException("File is empty");
@RequestBody Map<String, Object> updates) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
QuoteSession session = item.getQuoteSession(); // 1. Define Persistent Storage Path
if ("CONVERTED".equals(session.getStatus())) { // Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
} String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf("."))
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
// Save file
storageService.store(file, relativePath);
// Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
if (updates.containsKey("quantity")) { com.printcalculator.model.StlShiftResult shift = null;
item.setQuantity(parsePositiveQuantity(updates.get("quantity"))); try {
} // Apply Basic/Advanced Logic
if (updates.containsKey("color_code")) { applyPrintSettings(settings);
Object colorValue = updates.get("color_code");
if (colorValue != null) { // REAL SLICING
item.setColorCode(String.valueOf(colorValue)); // 1. Pick Machine (default to first active or specific)
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
// 2. Validate model size against machine volume
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
// 2b. Auto-center if needed (keeps the stored STL unchanged)
shift = stlService.shiftToFitIfNeeded(
persistentPath.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
// 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile();
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
}
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
}
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
if (settings.getMaterial() != null) {
if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA";
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
// Update Session Material
session.setMaterialCode(settings.getMaterial());
} else {
// Fallback if null?
session.setMaterialCode("pla_basic");
}
// Update Session Settings for Persistence
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
// Save session updates
sessionRepo.save(session);
String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process
// "standard" -> "0.20mm Standard @BBL A1"
// "draft" -> "0.28mm Extra Draft @BBL A1"
// "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca)
// Let's use robust defaults or simple overrides
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
}
// Build overrides map from settings
// Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) {
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
// If enabled, use a more permissive threshold (45 deg) by default
// to avoid expensive supports on things that don't strictly need them
if (settings.getSupportsEnabled()) {
processOverrides.put("support_threshold_angle", "45");
}
}
Map<String, String> machineOverrides = new HashMap<>();
if (settings.getNozzleDiameter() != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
}
// 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
sliceInput,
machineProfile,
filamentProfile,
processProfile,
machineOverrides, // machine overrides
processOverrides
);
// 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 6. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setQuantity(1);
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown);
// Dimensions from STL
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
return lineItemRepo.save(item);
} catch (Exception e) {
// Cleanup if failed
try {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
throw e;
} finally {
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
} }
} }
}
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
switch (quality) {
case "draft":
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "high":
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "standard":
default:
settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
}
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
}
}
// 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}")
@Transactional
public ResponseEntity<QuoteLineItem> updateLineItem(
@PathVariable UUID lineItemId,
@RequestBody Map<String, Object> updates
) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (updates.containsKey("quantity")) {
item.setQuantity((Integer) updates.get("quantity"));
}
if (updates.containsKey("color_code")) {
item.setColorCode((String) updates.get("color_code"));
}
// Recalculate price if needed?
// For now, unit price is fixed in mock. Total is calculated on GET.
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
return ResponseEntity.ok(lineItemRepo.save(item)); return ResponseEntity.ok(lineItemRepo.save(item));
} }
// 4. Delete Line Item
@DeleteMapping("/{sessionId}/line-items/{lineItemId}") @DeleteMapping("/{sessionId}/line-items/{lineItemId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteLineItem(@PathVariable UUID sessionId, public ResponseEntity<Void> deleteLineItem(
@PathVariable UUID lineItemId) { @PathVariable UUID sessionId,
@PathVariable UUID lineItemId
) {
// Verify item belongs to session?
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) { if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
lineItemRepo.delete(item); lineItemRepo.delete(item);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// 5. Get Session (Session + Items + Total)
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) { public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id); List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals)); // Calculate Totals
BigDecimal itemsTotal = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
itemsTotal = itemsTotal.add(lineTotal);
}
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal grandTotal = itemsTotal.add(setupFee);
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", items);
response.put("itemsTotalChf", itemsTotal);
response.put("grandTotalChf", grandTotal);
return ResponseEntity.ok(response);
} }
// 6. Download Line Item Content
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<Resource> downloadLineItemContent(@PathVariable UUID sessionId, public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
@PathVariable UUID lineItemId, @PathVariable UUID sessionId,
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview) @PathVariable UUID lineItemId
throws IOException { ) throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
@@ -150,93 +431,38 @@ public class QuoteSessionController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String targetStoredPath = item.getStoredPath(); if (item.getStoredPath() == null) {
if (preview) {
String convertedPath = quoteStorageService.extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
}
}
if (targetStoredPath == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId); Path path = Paths.get(item.getStoredPath());
if (path == null || !java.nio.file.Files.exists(path)) { // Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
// But loadAsResource expects relative path?
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
// If path is absolute, resolve might fail or behave weirdly.
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
// If we want to use storageService.loadAsResource, we need the relative path.
// Or we just access the file directly if we trust the absolute path.
// But we want to use StorageService abstraction.
// Option 1: Reconstruct relative path.
// We know structure: quotes/{sessionId}/{filename}...
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
// If we trust the file is on disk, we can use UrlResource directly here as before,
// relying on the fact that storedPath is the absolute path to the file.
// But we should verify it exists.
if (!Files.exists(path)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
Resource resource = new UrlResource(path.toUri()); org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri());
String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename();
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"") .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"")
.body(resource); .body(resource);
} }
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
@PathVariable UUID lineItemId)
throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
return ResponseEntity.notFound().build();
}
String targetStoredPath = item.getStoredPath();
if (targetStoredPath == null || targetStoredPath.isBlank()) {
return ResponseEntity.notFound().build();
}
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
if (path == null || !java.nio.file.Files.exists(path)) {
return ResponseEntity.notFound().build();
}
if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) {
return ResponseEntity.notFound().build();
}
Resource resource = new UrlResource(path.toUri());
String downloadName = path.getFileName().toString();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("model/stl"))
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
.body(resource);
}
private int parsePositiveQuantity(Object raw) {
if (raw == null) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity is required");
}
int quantity;
if (raw instanceof Number number) {
double numericValue = number.doubleValue();
if (!Double.isFinite(numericValue)) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be a finite number");
}
quantity = (int) Math.floor(numericValue);
} else {
try {
quantity = Integer.parseInt(String.valueOf(raw).trim());
} catch (NumberFormatException ex) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be an integer");
}
}
if (quantity < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be >= 1");
}
return quantity;
}
} }

View File

@@ -1,83 +0,0 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminLoginRequest;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.OptionalLong;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminSessionService adminSessionService;
private final AdminLoginThrottleService adminLoginThrottleService;
public AdminAuthController(
AdminSessionService adminSessionService,
AdminLoginThrottleService adminLoginThrottleService
) {
this.adminSessionService = adminSessionService;
this.adminLoginThrottleService = adminLoginThrottleService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@Valid @RequestBody AdminLoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse response
) {
String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest);
OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey);
if (remainingLock.isPresent()) {
long retryAfter = remainingLock.getAsLong();
return ResponseEntity.status(429)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
if (!adminSessionService.isPasswordValid(request.getPassword())) {
long retryAfter = adminLoginThrottleService.registerFailure(clientKey);
return ResponseEntity.status(401)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
adminLoginThrottleService.reset(clientKey);
String token = adminSessionService.createSessionToken();
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
return ResponseEntity.ok(Map.of(
"authenticated", true,
"expiresInMinutes", adminSessionService.getSessionTtlMinutes()
));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString());
return ResponseEntity.ok(Map.of("authenticated", false));
}
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me() {
return ResponseEntity.ok(Map.of("authenticated", true));
}
}

View File

@@ -1,82 +0,0 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.service.admin.AdminFilamentControllerService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true)
public class AdminFilamentController {
private final AdminFilamentControllerService adminFilamentControllerService;
public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) {
this.adminFilamentControllerService = adminFilamentControllerService;
}
@GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
return ResponseEntity.ok(adminFilamentControllerService.getMaterials());
}
@GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
return ResponseEntity.ok(adminFilamentControllerService.getVariants());
}
@PostMapping("/materials")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload));
}
@PutMapping("/materials/{materialTypeId}")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> updateMaterial(
@PathVariable Long materialTypeId,
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload));
}
@PostMapping("/variants")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.createVariant(payload));
}
@PutMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> updateVariant(
@PathVariable Long variantId,
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload));
}
@DeleteMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) {
adminFilamentControllerService.deleteVariant(variantId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -1,93 +0,0 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.service.admin.AdminOperationsControllerService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin")
@Transactional(readOnly = true)
public class AdminOperationsController {
private final AdminOperationsControllerService adminOperationsControllerService;
public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) {
this.adminOperationsControllerService = adminOperationsControllerService;
}
@GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock());
}
@GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
return ResponseEntity.ok(adminOperationsControllerService.getContactRequests());
}
@GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId));
}
@PatchMapping("/contact-requests/{requestId}/status")
@Transactional
public ResponseEntity<AdminContactRequestDetailDto> updateContactRequestStatus(
@PathVariable UUID requestId,
@RequestBody AdminUpdateContactRequestStatusRequest payload
) {
return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload));
}
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
public ResponseEntity<Resource> downloadContactRequestAttachment(
@PathVariable UUID requestId,
@PathVariable UUID attachmentId
) {
return adminOperationsControllerService.downloadContactRequestAttachment(requestId, attachmentId);
}
@GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions());
}
@GetMapping("/cad-invoices")
public ResponseEntity<List<AdminCadInvoiceDto>> getCadInvoices() {
return ResponseEntity.ok(adminOperationsControllerService.getCadInvoices());
}
@PostMapping("/cad-invoices")
@Transactional
public ResponseEntity<AdminCadInvoiceDto> createOrUpdateCadInvoice(
@RequestBody AdminCadInvoiceCreateRequest payload
) {
return ResponseEntity.ok(adminOperationsControllerService.createOrUpdateCadInvoice(payload));
}
@DeleteMapping("/sessions/{sessionId}")
@Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) {
adminOperationsControllerService.deleteQuoteSession(sessionId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -1,76 +0,0 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.service.order.AdminOrderControllerService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/orders")
@Transactional(readOnly = true)
public class AdminOrderController {
private final AdminOrderControllerService adminOrderControllerService;
public AdminOrderController(AdminOrderControllerService adminOrderControllerService) {
this.adminOrderControllerService = adminOrderControllerService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
return ResponseEntity.ok(adminOrderControllerService.listOrders());
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> updatePaymentMethod(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, payload));
}
@PostMapping("/{orderId}/status")
@Transactional
public ResponseEntity<OrderDto> updateOrderStatus(
@PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload
) {
return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload));
}
@GetMapping("/{orderId}/items/{orderItemId}/file")
public ResponseEntity<Resource> downloadOrderItemFile(
@PathVariable UUID orderId,
@PathVariable UUID orderItemId
) {
return adminOrderControllerService.downloadOrderItemFile(orderId, orderItemId);
}
@GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return adminOrderControllerService.downloadOrderConfirmation(orderId);
}
@GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return adminOrderControllerService.downloadOrderInvoice(orderId);
}
}

View File

@@ -1,52 +0,0 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminCadInvoiceCreateRequest {
private UUID sessionId;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private String notes;
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

View File

@@ -1,143 +0,0 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminCadInvoiceDto {
private UUID sessionId;
private String sessionStatus;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
private BigDecimal printItemsTotalChf;
private BigDecimal setupCostChf;
private BigDecimal shippingCostChf;
private BigDecimal grandTotalChf;
private UUID convertedOrderId;
private String convertedOrderStatus;
private String checkoutPath;
private String notes;
private OffsetDateTime createdAt;
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public String getSessionStatus() {
return sessionStatus;
}
public void setSessionStatus(String sessionStatus) {
this.sessionStatus = sessionStatus;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
public BigDecimal getPrintItemsTotalChf() {
return printItemsTotalChf;
}
public void setPrintItemsTotalChf(BigDecimal printItemsTotalChf) {
this.printItemsTotalChf = printItemsTotalChf;
}
public BigDecimal getSetupCostChf() {
return setupCostChf;
}
public void setSetupCostChf(BigDecimal setupCostChf) {
this.setupCostChf = setupCostChf;
}
public BigDecimal getShippingCostChf() {
return shippingCostChf;
}
public void setShippingCostChf(BigDecimal shippingCostChf) {
this.shippingCostChf = shippingCostChf;
}
public BigDecimal getGrandTotalChf() {
return grandTotalChf;
}
public void setGrandTotalChf(BigDecimal grandTotalChf) {
this.grandTotalChf = grandTotalChf;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
public String getConvertedOrderStatus() {
return convertedOrderStatus;
}
public void setConvertedOrderStatus(String convertedOrderStatus) {
this.convertedOrderStatus = convertedOrderStatus;
}
public String getCheckoutPath() {
return checkoutPath;
}
public void setCheckoutPath(String checkoutPath) {
this.checkoutPath = checkoutPath;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,52 +0,0 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestAttachmentDto {
private UUID id;
private String originalFilename;
private String mimeType;
private Long fileSizeBytes;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,125 +0,0 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminContactRequestDetailDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String contactPerson;
private String message;
private String status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private List<AdminContactRequestAttachmentDto> attachments;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getContactPerson() {
return contactPerson;
}
public void setContactPerson(String contactPerson) {
this.contactPerson = contactPerson;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public List<AdminContactRequestAttachmentDto> getAttachments() {
return attachments;
}
public void setAttachments(List<AdminContactRequestAttachmentDto> attachments) {
this.attachments = attachments;
}
}

View File

@@ -1,88 +0,0 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String status;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,49 +0,0 @@
package com.printcalculator.dto;
public class AdminFilamentMaterialTypeDto {
private Long id;
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -1,87 +0,0 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminFilamentStockDto {
private Long filamentVariantId;
private String materialCode;
private String variantDisplayName;
private String colorName;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean active;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
}

View File

@@ -1,187 +0,0 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public class AdminFilamentVariantDto {
private Long id;
private Long materialTypeId;
private String materialCode;
private Boolean materialIsFlexible;
private Boolean materialIsTechnical;
private String materialTechnicalTypeLabel;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean isActive;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getMaterialIsFlexible() {
return materialIsFlexible;
}
public void setMaterialIsFlexible(Boolean materialIsFlexible) {
this.materialIsFlexible = materialIsFlexible;
}
public Boolean getMaterialIsTechnical() {
return materialIsTechnical;
}
public void setMaterialIsTechnical(Boolean materialIsTechnical) {
this.materialIsTechnical = materialIsTechnical;
}
public String getMaterialTechnicalTypeLabel() {
return materialTechnicalTypeLabel;
}
public void setMaterialTechnicalTypeLabel(String materialTechnicalTypeLabel) {
this.materialTechnicalTypeLabel = materialTechnicalTypeLabel;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,17 +0,0 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.NotBlank;
public class AdminLoginRequest {
@NotBlank
private String password;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -1,13 +0,0 @@
package com.printcalculator.dto;
public class AdminOrderStatusUpdateRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -1,98 +0,0 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminQuoteSessionDto {
private UUID id;
private String status;
private String materialCode;
private OffsetDateTime createdAt;
private OffsetDateTime expiresAt;
private UUID convertedOrderId;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(OffsetDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
}

View File

@@ -1,13 +0,0 @@
package com.printcalculator.dto;
public class AdminUpdateContactRequestStatusRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -1,40 +0,0 @@
package com.printcalculator.dto;
public class AdminUpsertFilamentMaterialTypeRequest {
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -1,114 +0,0 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private Boolean isActive;
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -1,19 +1,11 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data; import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data @Data
public class CreateOrderRequest { public class CreateOrderRequest {
private CustomerDto customer; private CustomerDto customer;
private AddressDto billingAddress; private AddressDto billingAddress;
private AddressDto shippingAddress; private AddressDto shippingAddress;
private String language;
private boolean shippingSameAsBilling; private boolean shippingSameAsBilling;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
} }

View File

@@ -7,23 +7,12 @@ public record OptionsResponse(
List<QualityOption> qualities, List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns, List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights, List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters, List<NozzleOptionDTO> nozzleDiameters
List<NozzleLayerHeightOptionsDTO> layerHeightsByNozzle
) { ) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {} public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption( public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
Long id,
String name,
String colorName,
String hexColor,
String finishType,
Double stockSpools,
Double stockFilamentGrams,
boolean isOutOfStock
) {}
public record QualityOption(String id, String label) {} public record QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {} public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {} public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {}
public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List<LayerHeightOptionDTO> layerHeights) {}
} }

View File

@@ -7,13 +7,9 @@ import java.util.UUID;
public class OrderDto { public class OrderDto {
private UUID id; private UUID id;
private String orderNumber;
private String status; private String status;
private String paymentStatus;
private String paymentMethod;
private String customerEmail; private String customerEmail;
private String customerPhone; private String customerPhone;
private String preferredLanguage;
private String billingCustomerType; private String billingCustomerType;
private AddressDto billingAddress; private AddressDto billingAddress;
private AddressDto shippingAddress; private AddressDto shippingAddress;
@@ -23,46 +19,23 @@ public class OrderDto {
private BigDecimal shippingCostChf; private BigDecimal shippingCostChf;
private BigDecimal discountChf; private BigDecimal discountChf;
private BigDecimal subtotalChf; private BigDecimal subtotalChf;
private Boolean isCadOrder;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
private BigDecimal totalChf; private BigDecimal totalChf;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private String printMaterialCode;
private BigDecimal printNozzleDiameterMm;
private BigDecimal printLayerHeightMm;
private String printInfillPattern;
private Integer printInfillPercent;
private Boolean printSupportsEnabled;
private List<OrderItemDto> items; private List<OrderItemDto> items;
// Getters and Setters // Getters and Setters
public UUID getId() { return id; } public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; } public void setId(UUID id) { this.id = id; }
public String getOrderNumber() { return orderNumber; }
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
public String getPaymentStatus() { return paymentStatus; }
public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
public String getCustomerEmail() { return customerEmail; } public String getCustomerEmail() { return customerEmail; }
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; } public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
public String getCustomerPhone() { return customerPhone; } public String getCustomerPhone() { return customerPhone; }
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; } public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
public String getPreferredLanguage() { return preferredLanguage; }
public void setPreferredLanguage(String preferredLanguage) { this.preferredLanguage = preferredLanguage; }
public String getBillingCustomerType() { return billingCustomerType; } public String getBillingCustomerType() { return billingCustomerType; }
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; } public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
@@ -90,45 +63,12 @@ public class OrderDto {
public BigDecimal getSubtotalChf() { return subtotalChf; } public BigDecimal getSubtotalChf() { return subtotalChf; }
public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; } public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; }
public Boolean getIsCadOrder() { return isCadOrder; }
public void setIsCadOrder(Boolean isCadOrder) { this.isCadOrder = isCadOrder; }
public UUID getSourceRequestId() { return sourceRequestId; }
public void setSourceRequestId(UUID sourceRequestId) { this.sourceRequestId = sourceRequestId; }
public BigDecimal getCadHours() { return cadHours; }
public void setCadHours(BigDecimal cadHours) { this.cadHours = cadHours; }
public BigDecimal getCadHourlyRateChf() { return cadHourlyRateChf; }
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { this.cadHourlyRateChf = cadHourlyRateChf; }
public BigDecimal getCadTotalChf() { return cadTotalChf; }
public void setCadTotalChf(BigDecimal cadTotalChf) { this.cadTotalChf = cadTotalChf; }
public BigDecimal getTotalChf() { return totalChf; } public BigDecimal getTotalChf() { return totalChf; }
public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; } public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public String getPrintMaterialCode() { return printMaterialCode; }
public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; }
public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; }
public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; }
public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; }
public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; }
public String getPrintInfillPattern() { return printInfillPattern; }
public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; }
public Integer getPrintInfillPercent() { return printInfillPercent; }
public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; }
public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; }
public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; }
public List<OrderItemDto> getItems() { return items; } public List<OrderItemDto> getItems() { return items; }
public void setItems(List<OrderItemDto> items) { this.items = items; } public void setItems(List<OrderItemDto> items) { this.items = items; }
} }

View File

@@ -8,16 +8,6 @@ public class OrderItemDto {
private String originalFilename; private String originalFilename;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private Long filamentVariantId;
private String filamentVariantDisplayName;
private String filamentColorName;
private String filamentColorHex;
private String quality;
private BigDecimal nozzleDiameterMm;
private BigDecimal layerHeightMm;
private Integer infillPercent;
private String infillPattern;
private Boolean supportsEnabled;
private Integer quantity; private Integer quantity;
private Integer printTimeSeconds; private Integer printTimeSeconds;
private BigDecimal materialGrams; private BigDecimal materialGrams;
@@ -37,36 +27,6 @@ public class OrderItemDto {
public String getColorCode() { return colorCode; } public String getColorCode() { return colorCode; }
public void setColorCode(String colorCode) { this.colorCode = colorCode; } public void setColorCode(String colorCode) { this.colorCode = colorCode; }
public Long getFilamentVariantId() { return filamentVariantId; }
public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; }
public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; }
public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; }
public String getFilamentColorName() { return filamentColorName; }
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
public String getFilamentColorHex() { return filamentColorHex; }
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }
public String getQuality() { return quality; }
public void setQuality(String quality) { this.quality = quality; }
public BigDecimal getNozzleDiameterMm() { return nozzleDiameterMm; }
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { this.nozzleDiameterMm = nozzleDiameterMm; }
public BigDecimal getLayerHeightMm() { return layerHeightMm; }
public void setLayerHeightMm(BigDecimal layerHeightMm) { this.layerHeightMm = layerHeightMm; }
public Integer getInfillPercent() { return infillPercent; }
public void setInfillPercent(Integer infillPercent) { this.infillPercent = infillPercent; }
public String getInfillPattern() { return infillPattern; }
public void setInfillPattern(String infillPattern) { this.infillPattern = infillPattern; }
public Boolean getSupportsEnabled() { return supportsEnabled; }
public void setSupportsEnabled(Boolean supportsEnabled) { this.supportsEnabled = supportsEnabled; }
public Integer getQuantity() { return quantity; } public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; } public void setQuantity(Integer quantity) { this.quantity = quantity; }

View File

@@ -1,157 +1,24 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data;
@Data
public class PrintSettingsDto { public class PrintSettingsDto {
// Mode: "BASIC" or "ADVANCED" // Mode: "BASIC" or "ADVANCED"
private String complexityMode; private String complexityMode;
// Common // Common
private String material; // e.g. "PLA", "PLA TOUGH", "PETG" private String material; // e.g. "PLA", "PETG"
private String color; // e.g. "White", "#FFFFFF" private String color; // e.g. "White", "#FFFFFF"
private Integer quantity;
private Long filamentVariantId;
private Long printerMachineId;
// Basic Mode // Basic Mode
private String quality; // "draft", "standard", "high" private String quality; // "draft", "standard", "high"
// Advanced Mode (Optional in Basic) // Advanced Mode (Optional in Basic)
private Double nozzleDiameter;
private Double layerHeight; private Double layerHeight;
private Double infillDensity; private Double infillDensity;
private String infillPattern; private String infillPattern;
private Boolean supportsEnabled; private Boolean supportsEnabled = true;
private Double nozzleDiameter;
private String notes; private String notes;
// Dimensions
private Double boundingBoxX;
private Double boundingBoxY;
private Double boundingBoxZ;
public String getComplexityMode() {
return complexityMode;
}
public void setComplexityMode(String complexityMode) {
this.complexityMode = complexityMode;
}
public String getMaterial() {
return material;
}
public void setMaterial(String material) {
this.material = material;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public Long getPrinterMachineId() {
return printerMachineId;
}
public void setPrinterMachineId(Long printerMachineId) {
this.printerMachineId = printerMachineId;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public Double getNozzleDiameter() {
return nozzleDiameter;
}
public void setNozzleDiameter(Double nozzleDiameter) {
this.nozzleDiameter = nozzleDiameter;
}
public Double getLayerHeight() {
return layerHeight;
}
public void setLayerHeight(Double layerHeight) {
this.layerHeight = layerHeight;
}
public Double getInfillDensity() {
return infillDensity;
}
public void setInfillDensity(Double infillDensity) {
this.infillDensity = infillDensity;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Double getBoundingBoxX() {
return boundingBoxX;
}
public void setBoundingBoxX(Double boundingBoxX) {
this.boundingBoxX = boundingBoxX;
}
public Double getBoundingBoxY() {
return boundingBoxY;
}
public void setBoundingBoxY(Double boundingBoxY) {
this.boundingBoxY = boundingBoxY;
}
public Double getBoundingBoxZ() {
return boundingBoxZ;
}
public void setBoundingBoxZ(Double boundingBoxZ) {
this.boundingBoxZ = boundingBoxZ;
}
} }

View File

@@ -1,23 +1,15 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data; import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data @Data
public class QuoteRequestDto { public class QuoteRequestDto {
private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE"
private String customerType; // "PRIVATE" or "BUSINESS" private String customerType; // "PRIVATE" or "BUSINESS"
private String language; // "it" | "en" | "de" | "fr"
private String email; private String email;
private String phone; private String phone;
private String name; private String name;
private String companyName; private String companyName;
private String contactPerson; private String contactPerson;
private String message; private String message;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
} }

View File

@@ -24,16 +24,6 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; private String colorName;
@Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex;
@ColumnDefault("'GLOSSY'")
@Column(name = "finish_type", length = Integer.MAX_VALUE)
private String finishType;
@Column(name = "brand", length = Integer.MAX_VALUE)
private String brand;
@ColumnDefault("false") @ColumnDefault("false")
@Column(name = "is_matte", nullable = false) @Column(name = "is_matte", nullable = false)
private Boolean isMatte; private Boolean isMatte;
@@ -93,30 +83,6 @@ public class FilamentVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() { public Boolean getIsMatte() {
return isMatte; return isMatte;
} }
@@ -173,4 +139,4 @@ public class FilamentVariant {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
} }

View File

@@ -1,72 +0,0 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "filament_variant_orca_override", uniqueConstraints = {
@UniqueConstraint(name = "ux_filament_variant_orca_override_variant_machine", columnNames = {
"filament_variant_id", "printer_machine_profile_id"
})
})
public class FilamentVariantOrcaOverride {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_variant_orca_override_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_variant_id", nullable = false)
private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -1,72 +0,0 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "material_orca_profile_map", uniqueConstraints = {
@UniqueConstraint(name = "ux_material_orca_profile_map_machine_material", columnNames = {
"printer_machine_profile_id", "filament_material_type_id"
})
})
public class MaterialOrcaProfileMap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "material_orca_profile_map_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_material_type_id", nullable = false)
private FilamentMaterialType filamentMaterialType;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public FilamentMaterialType getFilamentMaterialType() {
return filamentMaterialType;
}
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
this.filamentMaterialType = filamentMaterialType;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -1,63 +0,0 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(
name = "nozzle_layer_height_option",
uniqueConstraints = @UniqueConstraint(
name = "ux_nozzle_layer_height_option_nozzle_layer",
columnNames = {"nozzle_diameter_mm", "layer_height_mm"}
)
)
public class NozzleLayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_layer_height_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -95,10 +95,6 @@ public class Order {
@Column(name = "shipping_country_code", length = 2) @Column(name = "shipping_country_code", length = 2)
private String shippingCountryCode; private String shippingCountryCode;
@ColumnDefault("'it'")
@Column(name = "preferred_language", length = 2)
private String preferredLanguage;
@ColumnDefault("'CHF'") @ColumnDefault("'CHF'")
@Column(name = "currency", nullable = false, length = 3) @Column(name = "currency", nullable = false, length = 3)
private String currency; private String currency;
@@ -119,23 +115,6 @@ public class Order {
@Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2) @Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal subtotalChf; private BigDecimal subtotalChf;
@ColumnDefault("false")
@Column(name = "is_cad_order", nullable = false)
private Boolean isCadOrder;
@Column(name = "source_request_id")
private UUID sourceRequestId;
@Column(name = "cad_hours", precision = 10, scale = 2)
private BigDecimal cadHours;
@Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2)
private BigDecimal cadHourlyRateChf;
@ColumnDefault("0.00")
@Column(name = "cad_total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal cadTotalChf;
@ColumnDefault("0.00") @ColumnDefault("0.00")
@Column(name = "total_chf", nullable = false, precision = 12, scale = 2) @Column(name = "total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal totalChf; private BigDecimal totalChf;
@@ -159,16 +138,6 @@ public class Order {
this.id = id; this.id = id;
} }
@Transient
public String getOrderNumber() {
if (id == null) {
return null;
}
String rawId = id.toString();
int dashIndex = rawId.indexOf('-');
return dashIndex > 0 ? rawId.substring(0, dashIndex) : rawId;
}
public QuoteSession getSourceQuoteSession() { public QuoteSession getSourceQuoteSession() {
return sourceQuoteSession; return sourceQuoteSession;
} }
@@ -377,14 +346,6 @@ public class Order {
this.currency = currency; this.currency = currency;
} }
public String getPreferredLanguage() {
return preferredLanguage;
}
public void setPreferredLanguage(String preferredLanguage) {
this.preferredLanguage = preferredLanguage;
}
public BigDecimal getSetupCostChf() { public BigDecimal getSetupCostChf() {
return setupCostChf; return setupCostChf;
} }
@@ -417,46 +378,6 @@ public class Order {
this.subtotalChf = subtotalChf; this.subtotalChf = subtotalChf;
} }
public Boolean getIsCadOrder() {
return isCadOrder;
}
public void setIsCadOrder(Boolean isCadOrder) {
this.isCadOrder = isCadOrder;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
public BigDecimal getTotalChf() { public BigDecimal getTotalChf() {
return totalChf; return totalChf;
} }
@@ -489,4 +410,5 @@ public class Order {
this.paidAt = paidAt; this.paidAt = paidAt;
} }
}
}

View File

@@ -44,28 +44,6 @@ public class OrderItem {
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode; private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant;
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@@ -79,15 +57,6 @@ public class OrderItem {
@Column(name = "material_grams", precision = 12, scale = 2) @Column(name = "material_grams", precision = 12, scale = 2)
private BigDecimal materialGrams; private BigDecimal materialGrams;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm;
@Column(name = "bounding_box_y_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxYMm;
@Column(name = "bounding_box_z_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxZMm;
@Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2) @Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal unitPriceChf; private BigDecimal unitPriceChf;
@@ -98,16 +67,6 @@ public class OrderItem {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@PrePersist
private void onCreate() {
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
if (quantity == null) {
quantity = 1;
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -180,62 +139,6 @@ public class OrderItem {
this.materialCode = materialCode; this.materialCode = materialCode;
} }
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }
@@ -268,30 +171,6 @@ public class OrderItem {
this.materialGrams = materialGrams; this.materialGrams = materialGrams;
} }
public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm;
}
public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) {
this.boundingBoxXMm = boundingBoxXMm;
}
public BigDecimal getBoundingBoxYMm() {
return boundingBoxYMm;
}
public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) {
this.boundingBoxYMm = boundingBoxYMm;
}
public BigDecimal getBoundingBoxZMm() {
return boundingBoxZMm;
}
public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) {
this.boundingBoxZMm = boundingBoxZMm;
}
public BigDecimal getUnitPriceChf() { public BigDecimal getUnitPriceChf() {
return unitPriceChf; return unitPriceChf;
} }
@@ -316,4 +195,4 @@ public class OrderItem {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
} }

View File

@@ -52,9 +52,6 @@ public class Payment {
@Column(name = "initiated_at", nullable = false) @Column(name = "initiated_at", nullable = false)
private OffsetDateTime initiatedAt; private OffsetDateTime initiatedAt;
@Column(name = "reported_at")
private OffsetDateTime reportedAt;
@Column(name = "received_at") @Column(name = "received_at")
private OffsetDateTime receivedAt; private OffsetDateTime receivedAt;
@@ -138,14 +135,6 @@ public class Payment {
this.initiatedAt = initiatedAt; this.initiatedAt = initiatedAt;
} }
public OffsetDateTime getReportedAt() {
return reportedAt;
}
public void setReportedAt(OffsetDateTime reportedAt) {
this.reportedAt = reportedAt;
}
public OffsetDateTime getReceivedAt() { public OffsetDateTime getReceivedAt() {
return receivedAt; return receivedAt;
} }

View File

@@ -41,6 +41,9 @@ public class PrinterMachine {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "slicer_machine_profile")
private String slicerMachineProfile;
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -57,6 +60,14 @@ public class PrinterMachine {
this.printerDisplayName = printerDisplayName; this.printerDisplayName = printerDisplayName;
} }
public String getSlicerMachineProfile() {
return slicerMachineProfile;
}
public void setSlicerMachineProfile(String slicerMachineProfile) {
this.slicerMachineProfile = slicerMachineProfile;
}
public Integer getBuildVolumeXMm() { public Integer getBuildVolumeXMm() {
return buildVolumeXMm; return buildVolumeXMm;
} }

View File

@@ -1,85 +0,0 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "printer_machine_profile", uniqueConstraints = {
@UniqueConstraint(name = "ux_printer_machine_profile_machine_nozzle", columnNames = {
"printer_machine_id", "nozzle_diameter_mm"
})
})
public class PrinterMachineProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "printer_machine_profile_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_id", nullable = false)
private PrinterMachine printerMachine;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "orca_machine_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaMachineProfileName;
@ColumnDefault("false")
@Column(name = "is_default", nullable = false)
private Boolean isDefault;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachine getPrinterMachine() {
return printerMachine;
}
public void setPrinterMachine(PrinterMachine printerMachine) {
this.printerMachine = printerMachine;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public String getOrcaMachineProfileName() {
return orcaMachineProfileName;
}
public void setOrcaMachineProfileName(String orcaMachineProfileName) {
this.orcaMachineProfileName = orcaMachineProfileName;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean isDefault) {
this.isDefault = isDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -40,32 +40,6 @@ public class QuoteLineItem {
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant;
@Column(name = "material_code", length = Integer.MAX_VALUE)
private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 6, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3) @Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm; private BigDecimal boundingBoxXMm;
@@ -150,70 +124,6 @@ public class QuoteLineItem {
this.colorCode = colorCode; this.colorCode = colorCode;
} }
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public BigDecimal getBoundingBoxXMm() { public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm; return boundingBoxXMm;
} }
@@ -302,4 +212,4 @@ public class QuoteLineItem {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
} }

View File

@@ -61,15 +61,6 @@ public class QuoteSession {
@Column(name = "converted_order_id") @Column(name = "converted_order_id")
private UUID convertedOrderId; private UUID convertedOrderId;
@Column(name = "source_request_id")
private UUID sourceRequestId;
@Column(name = "cad_hours", precision = 10, scale = 2)
private BigDecimal cadHours;
@Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2)
private BigDecimal cadHourlyRateChf;
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -182,28 +173,4 @@ public class QuoteSession {
this.convertedOrderId = convertedOrderId; this.convertedOrderId = convertedOrderId;
} }
public UUID getSourceRequestId() { }
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
}

View File

@@ -1,16 +0,0 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -1,16 +0,0 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderShippedEvent extends ApplicationEvent {
private final Order order;
public OrderShippedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -1,24 +0,0 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentConfirmedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentConfirmedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

@@ -1,25 +0,0 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentReportedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentReportedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

@@ -1,582 +0,0 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.text.NumberFormat;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.nio.file.Paths;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailListener {
private static final String DEFAULT_LANGUAGE = "it";
private final EmailNotificationService emailNotificationService;
private final InvoicePdfRenderingService invoicePdfRenderingService;
private final OrderItemRepository orderItemRepository;
private final QrBillService qrBillService;
private final StorageService storageService;
@Value("${app.mail.admin.enabled:true}")
private boolean adminMailEnabled;
@Value("${app.mail.admin.address:}")
private String adminMailAddress;
@Value("${app.frontend.base-url:http://localhost:4200}")
private String frontendBaseUrl;
@Async
@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
Order order = event.getOrder();
log.info("Processing OrderCreatedEvent for order id: {}", order.getId());
try {
sendCustomerConfirmationEmail(order);
if (adminMailEnabled && adminMailAddress != null && !adminMailAddress.isEmpty()) {
sendAdminNotificationEmail(order);
}
} catch (Exception e) {
log.error("Failed to process email notifications for order id: {}", order.getId(), e);
}
}
@Async
@EventListener
public void handlePaymentReportedEvent(PaymentReportedEvent event) {
Order order = event.getOrder();
log.info("Processing PaymentReportedEvent for order id: {}", order.getId());
try {
sendPaymentReportedEmail(order);
} catch (Exception e) {
log.error("Failed to send payment reported email for order id: {}", order.getId(), e);
}
}
@Async
@EventListener
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
Order order = event.getOrder();
Payment payment = event.getPayment();
log.info("Processing PaymentConfirmedEvent for order id: {}", order.getId());
try {
sendPaidInvoiceEmail(order, payment);
} catch (Exception e) {
log.error("Failed to send paid invoice email for order id: {}", order.getId(), e);
}
}
@Async
@EventListener
public void handleOrderShippedEvent(OrderShippedEvent event) {
Order order = event.getOrder();
log.info("Processing OrderShippedEvent for order id: {}", order.getId());
try {
sendOrderShippedEmail(order);
} catch (Exception e) {
log.error("Failed to send order shipped email for order id: {}", order.getId(), e);
}
}
private void sendCustomerConfirmationEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyOrderConfirmationTexts(templateData, language, orderNumber);
byte[] confirmationPdf = loadOrGenerateConfirmationPdf(order);
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
"order-confirmation",
templateData,
buildConfirmationAttachmentName(language, orderNumber),
confirmationPdf
);
}
private void sendPaymentReportedEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentReportedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
subject,
"payment-reported",
templateData
);
}
private void sendPaidInvoiceEmail(Order order, Payment payment) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentConfirmedTexts(templateData, language, orderNumber);
byte[] pdf = null;
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment);
} catch (Exception e) {
log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e);
}
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
"payment-confirmed",
templateData,
buildPaidInvoiceAttachmentName(language, orderNumber),
pdf
);
}
private void sendOrderShippedEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyOrderShippedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
subject,
"order-shipped",
templateData
);
}
private void sendAdminNotificationEmail(Order order) {
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
templateData.put("customerName", buildCustomerFullName(order));
templateData.put("emailTitle", "Nuovo ordine ricevuto");
templateData.put("headlineText", "Nuovo ordine #" + orderNumber);
templateData.put("greetingText", "Ciao team,");
templateData.put("introText", "Un nuovo ordine e' stato creato dal cliente.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Apri dettaglio ordine");
templateData.put("attachmentHintText", "La conferma cliente e il QR bill sono stati salvati nella cartella documenti dell'ordine.");
templateData.put("supportText", "Controlla i dettagli e procedi con la gestione operativa.");
templateData.put("footerText", "Notifica automatica sistema ordini.");
emailNotificationService.sendEmail(
adminMailAddress,
"Nuovo Ordine Ricevuto #" + orderNumber + " - " + buildCustomerFullName(order),
"order-confirmation",
templateData
);
}
private Map<String, Object> buildBaseTemplateData(Order order, String language) {
Locale locale = localeForLanguage(language);
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
currencyFormatter.setCurrency(Currency.getInstance("CHF"));
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", buildCustomerFirstName(order, language));
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order, language));
templateData.put(
"orderDate",
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
);
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("currentYear", Year.now().getValue());
return templateData;
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Confirmation");
templateData.put("headlineText", "Thank you for your order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your order and started processing it.");
templateData.put("detailsTitleText", "Order details");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("attachmentHintText", "Attached you can find the order confirmation PDF with the QR bill.");
templateData.put("supportText", "If you have questions, reply to this email and we will help you.");
templateData.put("footerText", "Automated message from 3D-Fab.");
yield "Order Confirmation #" + orderNumber + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellbestaetigung");
templateData.put("headlineText", "Danke fuer Ihre Bestellung #" + orderNumber);
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Bestellung erhalten und mit der Bearbeitung begonnen.");
templateData.put("detailsTitleText", "Bestelldetails");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelDate", "Datum");
templateData.put("labelTotal", "Gesamtbetrag");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("attachmentHintText", "Im Anhang finden Sie die Bestellbestaetigung mit QR-Rechnung.");
templateData.put("supportText", "Bei Fragen antworten Sie einfach auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
yield "Bestellbestaetigung #" + orderNumber + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Confirmation de commande");
templateData.put("headlineText", "Merci pour votre commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre commande et commence son traitement.");
templateData.put("detailsTitleText", "Details de commande");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("attachmentHintText", "Vous trouverez en piece jointe la confirmation de commande avec la facture QR.");
templateData.put("supportText", "Si vous avez des questions, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
yield "Confirmation de commande #" + orderNumber + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Conferma ordine");
templateData.put("headlineText", "Grazie per il tuo ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto il tuo ordine e iniziato l'elaborazione.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("attachmentHintText", "In allegato trovi la conferma ordine in PDF con QR bill.");
templateData.put("supportText", "Se hai domande, rispondi a questa email e ti aiutiamo subito.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
yield "Conferma Ordine #" + orderNumber + " - 3D-Fab";
}
};
}
private String applyPaymentReportedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Reported");
templateData.put("headlineText", "Payment reported for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your payment report and our team is now verifying it.");
templateData.put("statusText", "Current status: Payment under verification.");
templateData.put("orderDetailsCtaText", "Check order status");
templateData.put("supportText", "You will receive another email as soon as the payment is confirmed.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "We are verifying your payment (Order #" + orderNumber + ")";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung gemeldet");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " gemeldet");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Zahlungsmitteilung erhalten und pruefen sie aktuell.");
templateData.put("statusText", "Aktueller Status: Zahlung in Pruefung.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Sobald die Zahlung bestaetigt ist, erhalten Sie eine weitere E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Wir pruefen Ihre Zahlung (Bestellung #" + orderNumber + ")";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement signale");
templateData.put("headlineText", "Paiement signale pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre signalement de paiement et nous le verifions.");
templateData.put("statusText", "Statut actuel: Paiement en verification.");
templateData.put("orderDetailsCtaText", "Consulter le statut de la commande");
templateData.put("supportText", "Vous recevrez un nouvel email des que le paiement sera confirme.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Nous verifions votre paiement (Commande #" + orderNumber + ")";
}
default -> {
templateData.put("emailTitle", "Pagamento segnalato");
templateData.put("headlineText", "Pagamento segnalato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto la tua segnalazione di pagamento e la stiamo verificando.");
templateData.put("statusText", "Stato attuale: pagamento in verifica.");
templateData.put("orderDetailsCtaText", "Controlla lo stato ordine");
templateData.put("supportText", "Riceverai una nuova email non appena il pagamento sara' confermato.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Stiamo verificando il tuo pagamento (Ordine #" + orderNumber + ")";
}
};
}
private String applyPaymentConfirmedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Confirmed");
templateData.put("headlineText", "Payment confirmed for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Your payment has been confirmed and the order moved into production.");
templateData.put("statusText", "Current status: In production.");
templateData.put("attachmentHintText", "The paid invoice PDF is attached to this email.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "We will notify you again when the shipment is ready.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Payment confirmed (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung bestaetigt");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " bestaetigt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Ihre Zahlung wurde bestaetigt und die Bestellung ist jetzt in Produktion.");
templateData.put("statusText", "Aktueller Status: In Produktion.");
templateData.put("attachmentHintText", "Die bezahlte Rechnung als PDF ist dieser E-Mail beigefuegt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wir informieren Sie erneut, sobald der Versand bereit ist.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Zahlung bestaetigt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement confirme");
templateData.put("headlineText", "Paiement confirme pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Votre paiement est confirme et la commande est passe en production.");
templateData.put("statusText", "Statut actuel: En production.");
templateData.put("attachmentHintText", "La facture payee en PDF est jointe a cet email.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Nous vous informerons a nouveau des que l'expedition sera prete.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Paiement confirme (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Pagamento confermato");
templateData.put("headlineText", "Pagamento confermato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Il tuo pagamento e' stato confermato e l'ordine e' entrato in produzione.");
templateData.put("statusText", "Stato attuale: in produzione.");
templateData.put("attachmentHintText", "In allegato trovi la fattura saldata in PDF.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Ti aggiorneremo di nuovo quando la spedizione sara' pronta.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Pagamento confermato (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String applyOrderShippedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Shipped");
templateData.put("headlineText", "Your order #" + orderNumber + " has been shipped");
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Good news: your package has left our workshop and is on its way.");
templateData.put("statusText", "Current status: Shipped.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "If you need assistance, reply to this email.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Your order has been shipped (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellung versandt");
templateData.put("headlineText", "Ihre Bestellung #" + orderNumber + " wurde versandt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Gute Nachricht: Ihr Paket hat unsere Werkstatt verlassen und ist unterwegs.");
templateData.put("statusText", "Aktueller Status: Versandt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wenn Sie Hilfe benoetigen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Ihre Bestellung wurde versandt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Commande expediee");
templateData.put("headlineText", "Votre commande #" + orderNumber + " a ete expediee");
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Bonne nouvelle: votre colis a quitte notre atelier et est en route.");
templateData.put("statusText", "Statut actuel: Expediee.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Votre commande a ete expediee (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Ordine spedito");
templateData.put("headlineText", "Il tuo ordine #" + orderNumber + " e' stato spedito");
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Buone notizie: il tuo pacco e' partito dal nostro laboratorio ed e' in viaggio.");
templateData.put("statusText", "Stato attuale: spedito.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Se hai bisogno di assistenza, rispondi a questa email.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Il tuo ordine e' stato spedito (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private String buildOrderDetailsUrl(Order order, String language) {
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/" + language + "/co/" + order.getId();
}
private String buildConfirmationAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Order-Confirmation-" + orderNumber + ".pdf";
case "de" -> "Bestellbestaetigung-" + orderNumber + ".pdf";
case "fr" -> "Confirmation-Commande-" + orderNumber + ".pdf";
default -> "Conferma-Ordine-" + orderNumber + ".pdf";
};
}
private String buildPaidInvoiceAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Paid-Invoice-" + orderNumber + ".pdf";
case "de" -> "Bezahlte-Rechnung-" + orderNumber + ".pdf";
case "fr" -> "Facture-Payee-" + orderNumber + ".pdf";
default -> "Fattura-Pagata-" + orderNumber + ".pdf";
};
}
private byte[] loadOrGenerateConfirmationPdf(Order order) {
byte[] stored = loadStoredConfirmationPdf(order);
if (stored != null) {
return stored;
}
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
return invoicePdfRenderingService.generateDocumentPdf(order, items, true, qrBillService, null);
} catch (Exception e) {
log.error("Failed to generate fallback confirmation PDF for order id: {}", order.getId(), e);
return null;
}
}
private byte[] loadStoredConfirmationPdf(Order order) {
String relativePath = buildConfirmationPdfRelativePath(order);
try {
return storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
} catch (Exception e) {
log.warn("Confirmation PDF not found for order id {} at {}", order.getId(), relativePath);
return null;
}
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String buildCustomerFirstName(Order order, String language) {
if (order.getCustomer() != null && order.getCustomer().getFirstName() != null && !order.getCustomer().getFirstName().isBlank()) {
return order.getCustomer().getFirstName();
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()) {
return order.getBillingFirstName();
}
return switch (language) {
case "en" -> "Customer";
case "de" -> "Kunde";
case "fr" -> "Client";
default -> "Cliente";
};
}
private String buildCustomerFullName(Order order) {
String firstName = order.getCustomer() != null ? order.getCustomer().getFirstName() : null;
String lastName = order.getCustomer() != null ? order.getCustomer().getLastName() : null;
if (firstName != null && !firstName.isBlank() && lastName != null && !lastName.isBlank()) {
return firstName + " " + lastName;
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()
&& order.getBillingLastName() != null && !order.getBillingLastName().isBlank()) {
return order.getBillingFirstName() + " " + order.getBillingLastName();
}
return "Cliente";
}
private Locale localeForLanguage(String language) {
return switch (language) {
case "en" -> Locale.ENGLISH;
case "de" -> Locale.GERMAN;
case "fr" -> Locale.FRENCH;
default -> Locale.ITALIAN;
};
}
private String resolveLanguage(String language) {
if (language == null || language.isBlank()) {
return DEFAULT_LANGUAGE;
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> DEFAULT_LANGUAGE;
};
}
}

View File

@@ -1,83 +1,71 @@
package com.printcalculator.exception; package com.printcalculator.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest; import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.time.LocalDateTime; import java.util.HashMap;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice @ControllerAdvice
@Slf4j
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(ModelProcessingException.class) @ExceptionHandler(StorageException.class)
public ResponseEntity<Object> handleModelProcessingException( public ResponseEntity<?> handleStorageException(StorageException exc) {
ModelProcessingException ex, WebRequest request) { // Log the full exception for internal debugging
Map<String, Object> body = new LinkedHashMap<>(); log.error("Storage Exception occurred", exc);
body.put("timestamp", LocalDateTime.now());
body.put("status", HttpStatus.UNPROCESSABLE_ENTITY.value()); Map<String, String> response = new HashMap<>();
body.put("error", "Unprocessable Entity");
body.put("code", ex.getCode());
body.put("message", ex.getMessage());
body.put("path", extractPath(request));
return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_ENTITY); // Check for specific virus case
} if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
response.put("error", "Security Violation");
@ExceptionHandler(VirusDetectedException.class) // Safe message for client
public ResponseEntity<Object> handleVirusDetectedException( response.put("message", "File rejected by security policy.");
VirusDetectedException ex, WebRequest request) { response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Virus Detected");
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleValidationException(
MethodArgumentNotValidException ex, WebRequest request) {
List<String> details = new ArrayList<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
details.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
} }
Map<String, Object> body = new LinkedHashMap<>(); // Generic fallback for other storage errors to avoid leaking internal paths/details
body.put("timestamp", LocalDateTime.now()); response.put("error", "Storage Operation Failed");
body.put("message", "Dati non validi."); response.put("message", "Unable to process the file upload.");
body.put("error", "Validation Error"); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
body.put("details", details);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
} }
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Object> handleIllegalArgumentException( public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
IllegalArgumentException ex, WebRequest request) { Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
Map<String, Object> body = new LinkedHashMap<>(); response.put("message", "The uploaded file exceeds the maximum allowed size.");
body.put("timestamp", LocalDateTime.now()); return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
body.put("message", ex.getMessage());
body.put("error", "Bad Request");
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
} }
private String extractPath(WebRequest request) { @ExceptionHandler(ModelTooLargeException.class)
String raw = request.getDescription(false); public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
if (raw == null) { Map<String, String> response = new HashMap<>();
return ""; response.put("error", "Model too large");
} response.put("code", "MODEL_TOO_LARGE");
return raw.startsWith("uri=") ? raw.substring(4) : raw; response.put("message", String.format(
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
));
response.put("model_x_mm", formatMm(exc.getModelX()));
response.put("model_y_mm", formatMm(exc.getModelY()));
response.put("model_z_mm", formatMm(exc.getModelZ()));
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
return ResponseEntity.unprocessableEntity().body(response);
}
private String formatMm(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
} }
} }

View File

@@ -1,21 +0,0 @@
package com.printcalculator.exception;
import java.io.IOException;
public class ModelProcessingException extends IOException {
private final String code;
public ModelProcessingException(String code, String message) {
super(message);
this.code = code;
}
public ModelProcessingException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,45 @@
package com.printcalculator.exception;
public class ModelTooLargeException extends RuntimeException {
private final double modelX;
private final double modelY;
private final double modelZ;
private final int buildX;
private final int buildY;
private final int buildZ;
public ModelTooLargeException(double modelX, double modelY, double modelZ,
int buildX, int buildY, int buildZ) {
super("Model size exceeds build volume");
this.modelX = modelX;
this.modelY = modelY;
this.modelZ = modelZ;
this.buildX = buildX;
this.buildY = buildY;
this.buildZ = buildZ;
}
public double getModelX() {
return modelX;
}
public double getModelY() {
return modelY;
}
public double getModelZ() {
return modelZ;
}
public int getBuildX() {
return buildX;
}
public int getBuildY() {
return buildY;
}
public int getBuildZ() {
return buildZ;
}
}

View File

@@ -1,7 +0,0 @@
package com.printcalculator.exception;
public class VirusDetectedException extends RuntimeException {
public VirusDetectedException(String message) {
super(message);
}
}

View File

@@ -1,7 +0,0 @@
package com.printcalculator.model;
public record ModelDimensions(
double xMm,
double yMm,
double zMm
) {}

View File

@@ -1,8 +1,29 @@
package com.printcalculator.model; package com.printcalculator.model;
public record PrintStats( import lombok.AllArgsConstructor;
long printTimeSeconds, import lombok.Builder;
String printTimeFormatted, import lombok.Data;
double filamentWeightGrams, import lombok.NoArgsConstructor;
double filamentLengthMm
) {} @Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PrintStats {
private long printTimeSeconds;
private String printTimeFormatted;
private double filamentWeightGrams;
private double filamentLengthMm;
// Breakdown if available
private Double modelWeightGrams;
private Double supportWeightGrams;
// Legacy constructor for compatibility
public PrintStats(long printTimeSeconds, String printTimeFormatted, double filamentWeightGrams, double filamentLengthMm) {
this.printTimeSeconds = printTimeSeconds;
this.printTimeFormatted = printTimeFormatted;
this.filamentWeightGrams = filamentWeightGrams;
this.filamentLengthMm = filamentLengthMm;
}
}

View File

@@ -4,10 +4,13 @@ public class QuoteResult {
private double totalPrice; private double totalPrice;
private String currency; private String currency;
private PrintStats stats; private PrintStats stats;
public QuoteResult(double totalPrice, String currency, PrintStats stats) { private double setupCost;
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
this.totalPrice = totalPrice; this.totalPrice = totalPrice;
this.currency = currency; this.currency = currency;
this.stats = stats; this.stats = stats;
this.setupCost = setupCost;
} }
public double getTotalPrice() { public double getTotalPrice() {
@@ -21,4 +24,8 @@ public class QuoteResult {
public PrintStats getStats() { public PrintStats getStats() {
return stats; return stats;
} }
public double getSetupCost() {
return setupCost;
}
} }

View File

@@ -0,0 +1,16 @@
package com.printcalculator.model;
public record StlBounds(double minX, double minY, double minZ,
double maxX, double maxY, double maxZ) {
public double sizeX() {
return maxX - minX;
}
public double sizeY() {
return maxY - minY;
}
public double sizeZ() {
return maxZ - minZ;
}
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.model;
import java.nio.file.Path;
public record StlShiftResult(Path shiftedPath,
double offsetX,
double offsetY,
double offsetZ,
boolean shifted) {
}

View File

@@ -3,9 +3,7 @@ package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> { public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
List<CustomQuoteRequestAttachment> findByRequest_IdOrderByCreatedAtAsc(UUID requestId); }
}

View File

@@ -1,15 +0,0 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FilamentVariantOrcaOverrideRepository extends JpaRepository<FilamentVariantOrcaOverride, Long> {
Optional<FilamentVariantOrcaOverride> findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(
FilamentVariant filamentVariant,
PrinterMachineProfile printerMachineProfile
);
}

View File

@@ -2,18 +2,12 @@ package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> { public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<FilamentVariant> findByIsActiveTrue();
// We try to match by color name if possible, or get first active // We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
} }

View File

@@ -1,20 +0,0 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MaterialOrcaProfileMapRepository extends JpaRepository<MaterialOrcaProfileMap, Long> {
Optional<MaterialOrcaProfileMap> findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
PrinterMachineProfile printerMachineProfile,
FilamentMaterialType filamentMaterialType
);
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<MaterialOrcaProfileMap> findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile);
}

View File

@@ -1,10 +0,0 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleLayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NozzleLayerHeightOptionRepository extends JpaRepository<NozzleLayerHeightOption, Long> {
List<NozzleLayerHeightOption> findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
}

View File

@@ -3,9 +3,5 @@ package com.printcalculator.repository;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.Optional;
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> { public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
Optional<NozzleOption> findFirstByNozzleDiameterMmAndIsActiveTrue(BigDecimal nozzleDiameterMm); }
}

View File

@@ -8,5 +8,4 @@ import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> { public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId); List<OrderItem> findByOrder_Id(UUID orderId);
boolean existsByFilamentVariant_Id(Long filamentVariantId); }
}

View File

@@ -3,11 +3,7 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> { public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findAllByOrderByCreatedAtDesc(); }
boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId);
}

View File

@@ -3,9 +3,7 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> { public interface PaymentRepository extends JpaRepository<Payment, UUID> {
Optional<Payment> findByOrder_Id(UUID orderId);
} }

View File

@@ -1,15 +0,0 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
public interface PrinterMachineProfileRepository extends JpaRepository<PrinterMachineProfile, Long> {
Optional<PrinterMachineProfile> findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm);
Optional<PrinterMachineProfile> findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine);
List<PrinterMachineProfile> findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine);
}

View File

@@ -8,5 +8,4 @@ import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
boolean existsByFilamentVariant_Id(Long filamentVariantId); }
}

View File

@@ -8,6 +8,4 @@ import java.util.UUID;
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> { public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff); List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
}
List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses);
}

View File

@@ -1,101 +0,0 @@
package com.printcalculator.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.OptionalLong;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AdminLoginThrottleService {
private static final long BASE_DELAY_SECONDS = 2L;
private static final long MAX_DELAY_SECONDS = 3601L;
private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>();
private final boolean trustProxyHeaders;
public AdminLoginThrottleService(
@Value("${admin.auth.trust-proxy-headers:false}") boolean trustProxyHeaders
) {
this.trustProxyHeaders = trustProxyHeaders;
}
public OptionalLong getRemainingLockSeconds(String clientKey) {
LoginAttemptState state = attemptsByClient.get(clientKey);
if (state == null) {
return OptionalLong.empty();
}
long now = Instant.now().getEpochSecond();
long remaining = state.blockedUntilEpochSeconds - now;
if (remaining <= 0) {
attemptsByClient.remove(clientKey, state);
return OptionalLong.empty();
}
return OptionalLong.of(remaining);
}
public long registerFailure(String clientKey) {
long now = Instant.now().getEpochSecond();
LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> {
int nextFailures = current == null ? 1 : current.failures + 1;
long delay = calculateDelaySeconds(nextFailures);
return new LoginAttemptState(nextFailures, now + delay);
});
return calculateDelaySeconds(state.failures);
}
public void reset(String clientKey) {
attemptsByClient.remove(clientKey);
}
public String resolveClientKey(HttpServletRequest request) {
if (trustProxyHeaders) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
String[] parts = forwardedFor.split(",");
if (parts.length > 0 && !parts[0].trim().isEmpty()) {
return parts[0].trim();
}
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
}
String remoteAddress = request.getRemoteAddr();
if (remoteAddress != null && !remoteAddress.isBlank()) {
return remoteAddress.trim();
}
return "unknown";
}
private long calculateDelaySeconds(int failures) {
long delay = BASE_DELAY_SECONDS;
for (int i = 1; i < failures; i++) {
if (delay >= MAX_DELAY_SECONDS) {
return MAX_DELAY_SECONDS;
}
delay *= 2;
}
return Math.min(delay, MAX_DELAY_SECONDS);
}
private static class LoginAttemptState {
private final int failures;
private final long blockedUntilEpochSeconds;
private LoginAttemptState(int failures, long blockedUntilEpochSeconds) {
this.failures = failures;
this.blockedUntilEpochSeconds = blockedUntilEpochSeconds;
}
}
}

View File

@@ -1,71 +0,0 @@
package com.printcalculator.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component
public class AdminSessionAuthenticationFilter extends OncePerRequestFilter {
private final AdminSessionService adminSessionService;
public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) {
this.adminSessionService = adminSessionService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (!path.startsWith("/api/admin/")) {
return true;
}
return "/api/admin/auth/login".equals(path);
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional<String> token = adminSessionService.extractTokenFromCookies(request);
Optional<AdminSessionService.AdminSessionPayload> payload = token.flatMap(adminSessionService::validateSessionToken);
if (payload.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
return;
}
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
"admin",
null,
Collections.emptyList()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String resolvePath(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
return path.substring(contextPath.length());
}
return path;
}
}

View File

@@ -1,203 +0,0 @@
package com.printcalculator.security;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
@Service
public class AdminSessionService {
public static final String COOKIE_NAME = "admin_session";
private static final String COOKIE_PATH = "/api/admin";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final ObjectMapper objectMapper;
private final String adminPassword;
private final byte[] sessionSecret;
private final long sessionTtlMinutes;
public AdminSessionService(
ObjectMapper objectMapper,
@Value("${admin.password}") String adminPassword,
@Value("${admin.session.secret}") String sessionSecret,
@Value("${admin.session.ttl-minutes}") long sessionTtlMinutes
) {
this.objectMapper = objectMapper;
this.adminPassword = adminPassword;
this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8);
this.sessionTtlMinutes = sessionTtlMinutes;
validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes);
}
public boolean isPasswordValid(String candidatePassword) {
if (candidatePassword == null) {
return false;
}
return MessageDigest.isEqual(
adminPassword.getBytes(StandardCharsets.UTF_8),
candidatePassword.getBytes(StandardCharsets.UTF_8)
);
}
public String createSessionToken() {
Instant now = Instant.now();
AdminSessionPayload payload = new AdminSessionPayload(
now.getEpochSecond(),
now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(),
UUID.randomUUID().toString()
);
try {
String payloadJson = objectMapper.writeValueAsString(payload);
String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlEncode(sign(encodedPayload));
return encodedPayload + "." + signature;
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot create admin session token", e);
}
}
public Optional<AdminSessionPayload> validateSessionToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 2) {
return Optional.empty();
}
String encodedPayload = parts[0];
String encodedSignature = parts[1];
byte[] providedSignature;
try {
providedSignature = base64UrlDecode(encodedSignature);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
byte[] expectedSignature = sign(encodedPayload);
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
return Optional.empty();
}
try {
byte[] decodedPayload = base64UrlDecode(encodedPayload);
AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class);
if (payload.exp <= Instant.now().getEpochSecond()) {
return Optional.empty();
}
return Optional.of(payload);
} catch (IllegalArgumentException | IOException e) {
return Optional.empty();
}
}
public Optional<String> extractTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue());
}
}
return Optional.empty();
}
public ResponseCookie buildLoginCookie(String token) {
return ResponseCookie.from(COOKIE_NAME, token)
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ofMinutes(sessionTtlMinutes))
.build();
}
public ResponseCookie buildLogoutCookie() {
return ResponseCookie.from(COOKIE_NAME, "")
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ZERO)
.build();
}
public long getSessionTtlMinutes() {
return sessionTtlMinutes;
}
private byte[] sign(String encodedPayload) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM));
return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Cannot sign admin session token", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
private byte[] base64UrlDecode(String data) {
return Base64.getUrlDecoder().decode(data);
}
private void validateConfiguration(String password, String secret, long ttlMinutes) {
if (password == null || password.isBlank()) {
throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty");
}
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty");
}
if (secret.length() < 32) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long");
}
if (ttlMinutes <= 0) {
throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0");
}
}
public static class AdminSessionPayload {
@JsonProperty("iat")
public long iat;
@JsonProperty("exp")
public long exp;
@JsonProperty("nonce")
public String nonce;
public AdminSessionPayload() {
}
public AdminSessionPayload(long iat, long exp, String nonce) {
this.iat = iat;
this.exp = exp;
this.nonce = nonce;
}
}
}

View File

@@ -1,6 +1,5 @@
package com.printcalculator.service.storage; package com.printcalculator.service;
import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -23,15 +22,18 @@ public class ClamAVService {
public ClamAVService( public ClamAVService(
@Value("${clamav.host:clamav}") String host, @Value("${clamav.host:clamav}") String host,
@Value("${clamav.port:3310}") int port, @Value("${clamav.port:3310}") int port,
@Value("${clamav.enabled:true}") boolean enabled @Value("${clamav.enabled:false}") boolean enabled
) { ) {
this.enabled = enabled; this.enabled = enabled;
if (!enabled) {
logger.info("ClamAV is DISABLED");
this.clamavClient = null;
return;
}
logger.info("Initializing ClamAV client at {}:{}", host, port);
ClamavClient client = null; ClamavClient client = null;
try { try {
if (enabled) { client = new ClamavClient(host, port);
logger.info("Initializing ClamAV client at {}:{}", host, port);
client = new ClamavClient(host, port);
}
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to initialize ClamAV client: " + e.getMessage()); logger.error("Failed to initialize ClamAV client: " + e.getMessage());
} }
@@ -49,13 +51,11 @@ public class ClamAVService {
} else if (result instanceof ScanResult.VirusFound) { } else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("VIRUS DETECTED: {}", viruses); logger.warn("VIRUS DETECTED: {}", viruses);
throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses); return false;
} else { } else {
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result); logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
return true; return true;
} }
} catch (VirusDetectedException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e); logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
return true; return true;

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service.storage; package com.printcalculator.service;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.printcalculator.exception.StorageException; import com.printcalculator.exception.StorageException;
import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -20,12 +21,10 @@ import java.nio.file.StandardCopyOption;
public class FileSystemStorageService implements StorageService { public class FileSystemStorageService implements StorageService {
private final Path rootLocation; private final Path rootLocation;
private final Path normalizedRootLocation;
private final ClamAVService clamAVService; private final ClamAVService clamAVService;
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) { public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
this.rootLocation = Paths.get(storageLocation); this.rootLocation = Paths.get(storageLocation);
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
} }
@@ -40,7 +39,10 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(MultipartFile file, Path destinationRelativePath) throws IOException { public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
Path destinationFile = resolveInsideStorage(destinationRelativePath); Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi // 1. Salva prima il file su disco per evitare problemi di stream con file grandi
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
@@ -61,46 +63,32 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(Path source, Path destinationRelativePath) throws IOException { public void store(Path source, Path destinationRelativePath) throws IOException {
Path destinationFile = resolveInsideStorage(destinationRelativePath); Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING); Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
} }
@Override @Override
public void delete(Path path) throws IOException { public void delete(Path path) throws IOException {
Path file = resolveInsideStorage(path); Path file = rootLocation.resolve(path);
Files.deleteIfExists(file); Files.deleteIfExists(file);
} }
@Override @Override
public Resource loadAsResource(Path path) throws IOException { public Resource loadAsResource(Path path) throws IOException {
try { try {
Path file = resolveInsideStorage(path); Path file = rootLocation.resolve(path);
Resource resource = new UrlResource(file.toUri()); Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) { if (resource.exists() || resource.isReadable()) {
return resource; return resource;
} else { } else {
throw new StorageException("Could not read file: " + path); throw new RuntimeException("Could not read file: " + path);
} }
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
throw new StorageException("Could not read file: " + path, e); throw new RuntimeException("Could not read file: " + path, e);
} }
} }
private Path resolveInsideStorage(Path relativePath) {
if (relativePath == null) {
throw new StorageException("Path is required.");
}
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new StorageException("Cannot access absolute paths.");
}
Path resolved = normalizedRootLocation.resolve(normalizedRelative).normalize();
if (!resolved.startsWith(normalizedRootLocation)) {
throw new StorageException("Cannot access files outside storage root.");
}
return resolved;
}
} }

View File

@@ -26,13 +26,15 @@ public class GCodeParser {
private static final Pattern TIME_PATTERN = Pattern.compile( private static final Pattern TIME_PATTERN = Pattern.compile(
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)", ";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
Pattern.CASE_INSENSITIVE); Pattern.CASE_INSENSITIVE);
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)"); private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)"); private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
public PrintStats parse(File gcodeFile) throws IOException { public PrintStats parse(File gcodeFile) throws IOException {
long seconds = 0; long seconds = 0;
double weightG = 0; double weightG = 0;
double lengthMm = 0; double lengthMm = 0;
Double modelWeightG = null;
Double supportWeightG = null;
String timeFormatted = ""; String timeFormatted = "";
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) { try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
@@ -78,7 +80,14 @@ public class GCodeParser {
if (weightMatcher.find()) { if (weightMatcher.find()) {
try { try {
weightG = Double.parseDouble(weightMatcher.group(1).trim()); weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found weight: " + weightG + "g"); System.out.println("GCodeParser: Found total weight: " + weightG + "g");
// Check if we have groups 2 and 3 for breakdown
if (weightMatcher.groupCount() >= 3 && weightMatcher.group(2) != null) {
modelWeightG = Double.parseDouble(weightMatcher.group(2).trim());
supportWeightG = Double.parseDouble(weightMatcher.group(3).trim());
System.out.println("GCodeParser: Found breakdown - Model: " + modelWeightG + "g, Support: " + supportWeightG + "g");
}
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
} }
@@ -92,7 +101,14 @@ public class GCodeParser {
} }
} }
return new PrintStats(seconds, timeFormatted, weightG, lengthMm); return PrintStats.builder()
.printTimeSeconds(seconds)
.printTimeFormatted(timeFormatted)
.filamentWeightGrams(weightG)
.filamentLengthMm(lengthMm)
.modelWeightGrams(modelWeightG)
.supportWeightGrams(supportWeightG)
.build();
} }
private long parseTimeString(String timeStr) { private long parseTimeString(String timeStr) {

View File

@@ -0,0 +1,48 @@
package com.printcalculator.service;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
@Service
public class InvoicePdfRenderingService {
private final TemplateEngine thymeleafTemplateEngine;
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
}
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
try {
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg);
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
openHtmlToPdfRendererBuilder.useFastMode();
openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer());
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
openHtmlToPdfRendererBuilder.run();
return generatedPdfByteArrayOutputStream.toByteArray();
} catch (Exception pdfGenerationException) {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
}
}
}

View File

@@ -1,143 +0,0 @@
package com.printcalculator.service;
import com.printcalculator.entity.NozzleLayerHeightOption;
import com.printcalculator.repository.NozzleLayerHeightOptionRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class NozzleLayerHeightPolicyService {
private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP);
private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP);
private final NozzleLayerHeightOptionRepository ruleRepo;
public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) {
this.ruleRepo = ruleRepo;
}
public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() {
List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
if (rules.isEmpty()) {
return fallbackRules();
}
Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>();
for (NozzleLayerHeightOption rule : rules) {
BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm());
BigDecimal layer = normalizeLayer(rule.getLayerHeightMm());
if (nozzle == null || layer == null) {
continue;
}
byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer);
}
byNozzle.values().forEach(this::sortAndDeduplicate);
return byNozzle;
}
public BigDecimal normalizeNozzle(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal normalizeLayer(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(3, RoundingMode.HALF_UP);
}
public BigDecimal resolveNozzle(BigDecimal requestedNozzle) {
return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE);
}
public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) {
if (requestedLayer != null) {
return normalizeLayer(requestedLayer);
}
return defaultLayerForNozzle(nozzleDiameter);
}
public List<BigDecimal> allowedLayersForNozzle(BigDecimal nozzleDiameter) {
BigDecimal nozzle = resolveNozzle(nozzleDiameter);
List<BigDecimal> allowed = getActiveRulesByNozzle().get(nozzle);
return allowed != null ? allowed : List.of();
}
public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
BigDecimal layer = normalizeLayer(layerHeight);
if (layer == null) {
return false;
}
return allowedLayersForNozzle(nozzleDiameter)
.stream()
.anyMatch(allowed -> allowed.compareTo(layer) == 0);
}
public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return DEFAULT_LAYER;
}
BigDecimal preferred = normalizeLayer(DEFAULT_LAYER);
for (BigDecimal candidate : allowed) {
if (candidate.compareTo(preferred) == 0) {
return candidate;
}
}
return allowed.get(0);
}
public String allowedLayersLabel(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return "none";
}
return allowed.stream()
.map(value -> String.format(Locale.ROOT, "%.2f", value))
.reduce((a, b) -> a + ", " + b)
.orElse("none");
}
private void sortAndDeduplicate(List<BigDecimal> values) {
values.sort(Comparator.naturalOrder());
for (int i = values.size() - 1; i > 0; i--) {
if (values.get(i).compareTo(values.get(i - 1)) == 0) {
values.remove(i);
}
}
}
private Map<BigDecimal, List<BigDecimal>> fallbackRules() {
Map<BigDecimal, List<BigDecimal>> fallback = new LinkedHashMap<>();
fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12));
fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28));
fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36));
fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56));
return fallback;
}
private BigDecimal scaleNozzle(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
}
private List<BigDecimal> scaleLayers(double... values) {
List<BigDecimal> scaled = new ArrayList<>();
for (double value : values) {
scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP));
}
return scaled;
}
}

View File

@@ -1,152 +0,0 @@
package com.printcalculator.service;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentVariantOrcaOverrideRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Optional;
@Service
public class OrcaProfileResolver {
private final PrinterMachineProfileRepository machineProfileRepo;
private final MaterialOrcaProfileMapRepository materialMapRepo;
private final FilamentVariantOrcaOverrideRepository variantOverrideRepo;
public OrcaProfileResolver(
PrinterMachineProfileRepository machineProfileRepo,
MaterialOrcaProfileMapRepository materialMapRepo,
FilamentVariantOrcaOverrideRepository variantOverrideRepo
) {
this.machineProfileRepo = machineProfileRepo;
this.materialMapRepo = materialMapRepo;
this.variantOverrideRepo = variantOverrideRepo;
}
public ResolvedProfiles resolve(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm, FilamentVariant variant) {
Optional<PrinterMachineProfile> machineProfileOpt = resolveMachineProfile(printerMachine, nozzleDiameterMm);
String machineProfileName = machineProfileOpt
.map(PrinterMachineProfile::getOrcaMachineProfileName)
.orElseGet(() -> fallbackMachineProfile(printerMachine, nozzleDiameterMm));
String filamentProfileName = machineProfileOpt
.map(machineProfile -> resolveFilamentProfileWithMachineProfile(machineProfile, variant)
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType())))
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType()));
return new ResolvedProfiles(machineProfileName, filamentProfileName, machineProfileOpt.orElse(null));
}
public Optional<PrinterMachineProfile> resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null) {
return Optional.empty();
}
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle != null) {
Optional<PrinterMachineProfile> exact = machineProfileRepo
.findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle);
if (exact.isPresent()) {
return exact;
}
}
Optional<PrinterMachineProfile> defaultProfile = machineProfileRepo
.findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine);
if (defaultProfile.isPresent()) {
return defaultProfile;
}
return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine)
.stream()
.findFirst();
}
private Optional<String> resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) {
if (machineProfile == null || variant == null) {
return Optional.empty();
}
Optional<FilamentVariantOrcaOverride> override = variantOverrideRepo
.findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile);
if (override.isPresent()) {
return Optional.ofNullable(override.get().getOrcaFilamentProfileName());
}
Optional<MaterialOrcaProfileMap> map = materialMapRepo
.findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
machineProfile,
variant.getFilamentMaterialType()
);
return map.map(MaterialOrcaProfileMap::getOrcaFilamentProfileName);
}
private String fallbackMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null || machine.getPrinterDisplayName() == null || machine.getPrinterDisplayName().isBlank()) {
return "Bambu Lab A1 0.4 nozzle";
}
String displayName = machine.getPrinterDisplayName();
if (displayName.toLowerCase().contains("bambulab a1") || displayName.toLowerCase().contains("bambu lab a1")) {
String nozzleForProfile = formatNozzleForProfileName(nozzleDiameterMm);
if (nozzleForProfile == null) {
return "Bambu Lab A1 0.4 nozzle";
}
return "Bambu Lab A1 " + nozzleForProfile + " nozzle";
}
return displayName;
}
private String fallbackFilamentProfile(FilamentMaterialType materialType) {
String materialCode = materialType != null && materialType.getMaterialCode() != null
? materialType.getMaterialCode().trim().toUpperCase()
: "PLA";
return switch (materialCode) {
case "PLA TOUGH" -> "Bambu PLA Tough @BBL A1";
case "PETG" -> "Generic PETG";
case "TPU" -> "Generic TPU";
case "PC" -> "Generic PC";
case "ABS" -> "Generic ABS";
default -> "Generic PLA";
};
}
private BigDecimal normalizeNozzle(BigDecimal nozzleDiameterMm) {
if (nozzleDiameterMm == null) {
return null;
}
return nozzleDiameterMm.setScale(2, RoundingMode.HALF_UP);
}
private String formatNozzleForProfileName(BigDecimal nozzleDiameterMm) {
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle == null) {
return null;
}
BigDecimal stripped = normalizedNozzle.stripTrailingZeros();
if (stripped.scale() < 0) {
stripped = stripped.setScale(0);
}
return stripped.toPlainString();
}
public record ResolvedProfiles(
String machineProfileName,
String filamentProfileName,
PrinterMachineProfile machineProfile
) {}
}

View File

@@ -1,5 +1,6 @@
package com.printcalculator.service; package com.printcalculator.service;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest; import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*; import com.printcalculator.entity.*;
import com.printcalculator.repository.CustomerRepository; import com.printcalculator.repository.CustomerRepository;
@@ -7,28 +8,23 @@ import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -38,9 +34,6 @@ public class OrderService {
private final StorageService storageService; private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService;
private final QuoteSessionTotalsService quoteSessionTotalsService;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -49,10 +42,7 @@ public class OrderService {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService) {
ApplicationEventPublisher eventPublisher,
PaymentService paymentService,
QuoteSessionTotalsService quoteSessionTotalsService) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -61,17 +51,10 @@ public class OrderService {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
this.quoteSessionTotalsService = quoteSessionTotalsService;
} }
@Transactional @Transactional
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) { public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
if (!request.isAcceptTerms() || !request.isAcceptPrivacy()) {
throw new IllegalArgumentException("Accettazione Termini e Privacy obbligatoria.");
}
QuoteSession session = quoteSessionRepo.findById(quoteSessionId) QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
.orElseThrow(() -> new RuntimeException("Quote Session not found")); .orElseThrow(() -> new RuntimeException("Quote Session not found"));
@@ -91,14 +74,6 @@ public class OrderService {
customer.setPhone(request.getCustomer().getPhone()); customer.setPhone(request.getCustomer().getPhone());
customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setCustomerType(request.getCustomer().getCustomerType());
if (request.getBillingAddress() != null) {
customer.setFirstName(request.getBillingAddress().getFirstName());
customer.setLastName(request.getBillingAddress().getLastName());
customer.setCompanyName(request.getBillingAddress().getCompanyName());
customer.setContactPerson(request.getBillingAddress().getContactPerson());
}
customer.setUpdatedAt(OffsetDateTime.now()); customer.setUpdatedAt(OffsetDateTime.now());
customerRepo.save(customer); customerRepo.save(customer);
@@ -110,7 +85,6 @@ public class OrderService {
order.setStatus("PENDING_PAYMENT"); order.setStatus("PENDING_PAYMENT");
order.setCreatedAt(OffsetDateTime.now()); order.setCreatedAt(OffsetDateTime.now());
order.setUpdatedAt(OffsetDateTime.now()); order.setUpdatedAt(OffsetDateTime.now());
order.setPreferredLanguage(normalizeLanguage(request.getLanguage()));
order.setCurrency("CHF"); order.setCurrency("CHF");
order.setBillingCustomerType(request.getCustomer().getCustomerType()); order.setBillingCustomerType(request.getCustomer().getCustomerType());
@@ -150,20 +124,13 @@ public class OrderService {
} }
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, quoteItems);
BigDecimal cadTotal = totals.cadTotalChf();
BigDecimal subtotal = BigDecimal.ZERO; BigDecimal subtotal = BigDecimal.ZERO;
order.setSubtotalChf(BigDecimal.ZERO); order.setSubtotalChf(BigDecimal.ZERO);
order.setTotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(totals.setupCostChf()); order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
order.setShippingCostChf(totals.shippingCostChf()); order.setShippingCostChf(BigDecimal.valueOf(9.00));
order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus()));
order.setSourceRequestId(session.getSourceRequestId());
order.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO);
order.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO);
order.setCadTotalChf(cadTotal);
order = orderRepo.save(order); order = orderRepo.save(order);
@@ -173,40 +140,14 @@ public class OrderService {
OrderItem oItem = new OrderItem(); OrderItem oItem = new OrderItem();
oItem.setOrder(order); oItem.setOrder(order);
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1; oItem.setQuantity(qItem.getQuantity());
oItem.setQuantity(quantity);
oItem.setColorCode(qItem.getColorCode()); oItem.setColorCode(qItem.getColorCode());
oItem.setFilamentVariant(qItem.getFilamentVariant()); oItem.setMaterialCode(session.getMaterialCode());
if (qItem.getFilamentVariant() != null
&& qItem.getFilamentVariant().getFilamentMaterialType() != null
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
} else {
oItem.setMaterialCode(session.getMaterialCode());
}
oItem.setQuality(qItem.getQuality());
oItem.setNozzleDiameterMm(qItem.getNozzleDiameterMm());
oItem.setLayerHeightMm(qItem.getLayerHeightMm());
oItem.setInfillPercent(qItem.getInfillPercent());
oItem.setInfillPattern(qItem.getInfillPattern());
oItem.setSupportsEnabled(qItem.getSupportsEnabled());
BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO; oItem.setUnitPriceChf(qItem.getUnitPriceChf());
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
distributedUnitPrice = distributedUnitPrice.add(unitMachineCost);
}
oItem.setUnitPriceChf(distributedUnitPrice);
oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(quantity)));
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
oItem.setMaterialGrams(qItem.getMaterialGrams()); oItem.setMaterialGrams(qItem.getMaterialGrams());
oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm());
oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm());
oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm());
UUID fileUuid = UUID.randomUUID(); UUID fileUuid = UUID.randomUUID();
String ext = getExtension(qItem.getOriginalFilename()); String ext = getExtension(qItem.getOriginalFilename());
@@ -222,15 +163,16 @@ public class OrderService {
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath); oItem.setStoredRelativePath(relativePath);
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); if (qItem.getStoredPath() != null) {
if (sourcePath == null || !Files.exists(sourcePath)) { try {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); Path sourcePath = Paths.get(qItem.getStoredPath());
} if (Files.exists(sourcePath)) {
try { storageService.store(sourcePath, Paths.get(relativePath));
storageService.store(sourcePath, Paths.get(relativePath)); oItem.setFileSizeBytes(Files.size(sourcePath));
oItem.setFileSizeBytes(Files.size(sourcePath)); }
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); e.printStackTrace();
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
@@ -238,12 +180,12 @@ public class OrderService {
subtotal = subtotal.add(oItem.getLineTotalChf()); subtotal = subtotal.add(oItem.getLineTotalChf());
} }
order.setSubtotalChf(subtotal.add(cadTotal)); order.setSubtotalChf(subtotal);
if (order.getShippingCostChf() == null) {
BigDecimal total = order.getSubtotalChf() order.setShippingCostChf(BigDecimal.valueOf(9.00));
.add(order.getSetupCostChf()) }
.add(order.getShippingCostChf())
.subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total); order.setTotalChf(total);
session.setConvertedOrderId(order.getId()); session.setConvertedOrderId(order.getId());
@@ -253,25 +195,79 @@ public class OrderService {
// Generate Invoice and QR Bill // Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems); generateAndSaveDocuments(order, savedItems);
Order savedOrder = orderRepo.save(order); return orderRepo.save(order);
// ALWAYS initialize payment as PENDING
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder;
} }
private void generateAndSaveDocuments(Order order, List<OrderItem> items) { private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
try { try {
// 1. Generate and save the raw QR Bill for internal traceability. // 1. Generate QR Bill
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order); byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
saveFileBytes(qrBillSvgBytes, buildQrBillSvgRelativePath(order)); String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8);
// 2. Generate and save the same confirmation PDF served by /api/orders/{id}/confirmation. // Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
byte[] confirmationPdfBytes = invoiceService.generateDocumentPdf(order, items, true, qrBillService, null); if (qrBillSvg.contains("<?xml")) {
saveFileBytes(confirmationPdfBytes, buildConfirmationPdfRelativePath(order)); int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
// Save QR Bill SVG
String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg";
saveFileBytes(qrBillSvgBytes, qrRelativePath);
// 2. Prepare Invoice Variables
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = "BUSINESS".equals(order.getBillingCustomerType())
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
// 3. Generate PDF
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
// Save PDF
String pdfRelativePath = "orders/" + order.getId() + "/documents/invoice-" + order.getId() + ".pdf";
saveFileBytes(pdfBytes, pdfRelativePath);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
@@ -301,53 +297,4 @@ public class OrderService {
} }
return "stl"; return "stl";
} }
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private String buildQrBillSvgRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/qr-bill.svg";
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> "it";
};
}
} }

View File

@@ -7,46 +7,31 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.math.BigDecimal;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service @Service
public class ProfileManager { public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
private static final Pattern LAYER_MM_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)?)mm\\b", Pattern.CASE_INSENSITIVE);
private final String profilesRoot; private final String profilesRoot;
private final Path resolvedProfilesRoot;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, String> profileAliases; private final Map<String, String> profileAliases;
private volatile List<ProcessProfileMeta> cachedProcessProfiles;
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) { public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
this.profilesRoot = profilesRoot; this.profilesRoot = profilesRoot;
this.mapper = mapper; this.mapper = mapper;
this.profileAliases = new HashMap<>(); this.profileAliases = new HashMap<>();
initializeAliases(); initializeAliases();
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
} }
private void initializeAliases() { private void initializeAliases() {
@@ -55,7 +40,6 @@ public class ProfileManager {
// Material Aliases // Material Aliases
profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1"); profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1");
profileAliases.put("pla_tough", "Bambu PLA Tough @BBL A1");
profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1"); profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1");
profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1"); profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1");
@@ -71,137 +55,42 @@ public class ProfileManager {
public ObjectNode getMergedProfile(String profileName, String type) throws IOException { public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
Path profilePath = findProfileFile(profileName, type); Path profilePath = findProfileFile(profileName, type);
if (profilePath == null) { if (profilePath == null) {
throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")"); throw new IOException("Profile not found: " + profileName);
} }
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
public List<BigDecimal> findCompatibleProcessLayers(String machineProfileName) { public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
if (machineProfileName == null || machineProfileName.isBlank()) { String resolvedName = profileAliases.getOrDefault(machineName, machineName);
return List.of(); if (nozzleDiameter == null) return resolvedName;
}
Set<BigDecimal> layers = new LinkedHashSet<>(); String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) { String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
if (meta.compatiblePrinters().contains(machineProfileName) && meta.layerHeightMm() != null) { String candidate = base + " " + formatted + " nozzle";
layers.add(meta.layerHeightMm());
}
}
if (layers.isEmpty()) {
return List.of();
}
List<BigDecimal> sorted = new ArrayList<>(layers); Path exists = findProfileFile(candidate, "machine");
sorted.sort(Comparator.naturalOrder()); return exists != null ? candidate : resolvedName;
return sorted;
}
public Optional<String> findCompatibleProcessProfileName(String machineProfileName,
BigDecimal layerHeightMm,
String qualityHint) {
if (machineProfileName == null || machineProfileName.isBlank() || layerHeightMm == null) {
return Optional.empty();
}
BigDecimal normalizedLayer = layerHeightMm.setScale(3, RoundingMode.HALF_UP);
String normalizedQuality = String.valueOf(qualityHint == null ? "" : qualityHint)
.trim()
.toLowerCase(Locale.ROOT);
List<ProcessProfileMeta> candidates = new ArrayList<>();
for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) {
if (!meta.compatiblePrinters().contains(machineProfileName)) {
continue;
}
if (meta.layerHeightMm() == null || meta.layerHeightMm().compareTo(normalizedLayer) != 0) {
continue;
}
candidates.add(meta);
}
if (candidates.isEmpty()) {
return Optional.empty();
}
candidates.sort(Comparator
.comparingInt((ProcessProfileMeta meta) -> scoreProcessForQuality(meta.name(), normalizedQuality))
.reversed()
.thenComparing(ProcessProfileMeta::name, String.CASE_INSENSITIVE_ORDER));
return Optional.ofNullable(candidates.get(0).name());
} }
private Path findProfileFile(String name, String type) { private Path findProfileFile(String name, String type) {
if (!Files.isDirectory(resolvedProfilesRoot)) {
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
return null;
}
// Check aliases first // Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name); String resolvedName = profileAliases.getOrDefault(name, name);
// Look for name.json under the expected type directory first to avoid // Simple search: look for name.json in the profiles_root recursively
// collisions across vendors/profile families with same filename. // Type could be "machine", "process", "filament" to narrow down, but for now global search
String filename = toJsonFilename(resolvedName); String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) { try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
List<Path> candidates = stream Optional<Path> found = stream
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.sorted() .findFirst();
.toList(); return found.orElse(null);
if (candidates.isEmpty()) {
return null;
}
if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) {
Optional<Path> typed = candidates.stream()
.filter(p -> pathContainsSegment(p, type))
.findFirst();
if (typed.isPresent()) {
return typed.get();
}
}
return candidates.get(0);
} catch (IOException e) { } catch (IOException e) {
logger.severe("Error searching for profile: " + e.getMessage()); logger.severe("Error searching for profile: " + e.getMessage());
return null; return null;
} }
} }
private Path resolveProfilesRoot(String configuredRoot) {
Set<Path> candidates = new LinkedHashSet<>();
Path cwd = Paths.get("").toAbsolutePath().normalize();
if (configuredRoot != null && !configuredRoot.isBlank()) {
Path configured = Paths.get(configuredRoot);
candidates.add(configured.toAbsolutePath().normalize());
if (!configured.isAbsolute()) {
candidates.add(cwd.resolve(configuredRoot).normalize());
}
}
candidates.add(cwd.resolve("profiles").normalize());
candidates.add(cwd.resolve("backend/profiles").normalize());
candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize());
List<String> checkedPaths = new ArrayList<>();
for (Path candidate : candidates) {
checkedPaths.add(candidate.toString());
if (Files.isDirectory(candidate)) {
return candidate;
}
}
logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths));
if (configuredRoot != null && !configuredRoot.isBlank()) {
return Paths.get(configuredRoot).toAbsolutePath().normalize();
}
return cwd.resolve("profiles").normalize();
}
private ObjectNode resolveInheritance(Path currentPath) throws IOException { private ObjectNode resolveInheritance(Path currentPath) throws IOException {
// 1. Load current // 1. Load current
JsonNode currentNode = mapper.readTree(currentPath.toFile()); JsonNode currentNode = mapper.readTree(currentPath.toFile());
@@ -209,20 +98,14 @@ public class ProfileManager {
// 2. Check inherits // 2. Check inherits
if (currentNode.has("inherits")) { if (currentNode.has("inherits")) {
String parentName = currentNode.get("inherits").asText(); String parentName = currentNode.get("inherits").asText();
// Try local directory first with explicit .json filename. // Try to find parent in same directory or standard search
String parentFilename = toJsonFilename(parentName); Path parentPath = currentPath.getParent().resolve(parentName);
Path parentPath = currentPath.getParent().resolve(parentFilename);
if (!Files.exists(parentPath)) { if (!Files.exists(parentPath)) {
// Fallback to the same profile type directory before global. // If not in same dir, search globally
String inferredType = inferTypeFromPath(currentPath);
parentPath = findProfileFile(parentName, inferredType);
}
if (parentPath == null || !Files.exists(parentPath)) {
parentPath = findProfileFile(parentName, "any"); parentPath = findProfileFile(parentName, "any");
} }
if (parentPath != null && Files.exists(parentPath)) { if (parentPath != null && Files.exists(parentPath)) {
logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath);
// Recursive call // Recursive call
ObjectNode parentNode = resolveInheritance(parentPath); ObjectNode parentNode = resolveInheritance(parentPath);
// Merge current into parent (child overrides parent) // Merge current into parent (child overrides parent)
@@ -253,151 +136,4 @@ public class ProfileManager {
mainNode.set(fieldName, jsonNode); mainNode.set(fieldName, jsonNode);
} }
} }
private String toJsonFilename(String name) {
return name.endsWith(".json") ? name : name + ".json";
}
private boolean pathContainsSegment(Path path, String segment) {
String normalized = path.toString().replace('\\', '/');
String needle = "/" + segment + "/";
return normalized.contains(needle);
}
private String inferTypeFromPath(Path path) {
if (path == null) {
return "any";
}
if (pathContainsSegment(path, "machine")) {
return "machine";
}
if (pathContainsSegment(path, "process")) {
return "process";
}
if (pathContainsSegment(path, "filament")) {
return "filament";
}
return "any";
}
private List<ProcessProfileMeta> getOrLoadProcessProfiles() {
List<ProcessProfileMeta> cached = cachedProcessProfiles;
if (cached != null) {
return cached;
}
synchronized (this) {
if (cachedProcessProfiles != null) {
return cachedProcessProfiles;
}
List<ProcessProfileMeta> loaded = new ArrayList<>();
if (!Files.isDirectory(resolvedProfilesRoot)) {
cachedProcessProfiles = Collections.emptyList();
return cachedProcessProfiles;
}
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> processFiles = stream
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"))
.filter(path -> pathContainsSegment(path, "process"))
.sorted()
.toList();
for (Path processFile : processFiles) {
try {
JsonNode node = mapper.readTree(processFile.toFile());
if (!"process".equalsIgnoreCase(node.path("type").asText())) {
continue;
}
String name = node.path("name").asText("");
if (name.isBlank()) {
continue;
}
BigDecimal layer = extractLayerHeightFromProfileName(name);
if (layer == null) {
continue;
}
Set<String> compatiblePrinters = new LinkedHashSet<>();
JsonNode compatibleNode = node.path("compatible_printers");
if (compatibleNode.isArray()) {
compatibleNode.forEach(value -> {
String printer = value.asText("").trim();
if (!printer.isBlank()) {
compatiblePrinters.add(printer);
}
});
}
if (compatiblePrinters.isEmpty()) {
continue;
}
loaded.add(new ProcessProfileMeta(name, layer, compatiblePrinters));
} catch (Exception ignored) {
// Ignore malformed or non-process JSON files.
}
}
} catch (IOException e) {
logger.warning("Failed to scan process profiles: " + e.getMessage());
}
cachedProcessProfiles = List.copyOf(loaded);
return cachedProcessProfiles;
}
}
private BigDecimal extractLayerHeightFromProfileName(String profileName) {
if (profileName == null) {
return null;
}
Matcher matcher = LAYER_MM_PATTERN.matcher(profileName.trim());
if (!matcher.find()) {
return null;
}
try {
return new BigDecimal(matcher.group(1)).setScale(3, RoundingMode.HALF_UP);
} catch (NumberFormatException ex) {
return null;
}
}
private int scoreProcessForQuality(String processName, String qualityHint) {
String normalizedName = String.valueOf(processName == null ? "" : processName)
.toLowerCase(Locale.ROOT);
if (qualityHint == null || qualityHint.isBlank()) {
return 0;
}
return switch (qualityHint) {
case "draft" -> {
if (normalizedName.contains("extra draft")) yield 30;
if (normalizedName.contains("draft")) yield 20;
if (normalizedName.contains("standard")) yield 10;
yield 0;
}
case "extra_fine", "high", "high_definition" -> {
if (normalizedName.contains("extra fine")) yield 30;
if (normalizedName.contains("high quality")) yield 25;
if (normalizedName.contains("fine")) yield 20;
if (normalizedName.contains("standard")) yield 5;
yield 0;
}
default -> {
if (normalizedName.contains("standard")) yield 30;
if (normalizedName.contains("optimal")) yield 25;
if (normalizedName.contains("strength")) yield 20;
if (normalizedName.contains("high quality")) yield 10;
if (normalizedName.contains("draft")) yield 5;
yield 0;
}
};
}
private record ProcessProfileMeta(String name, BigDecimal layerHeightMm, Set<String> compatiblePrinters) {
}
} }

View File

@@ -1,10 +1,13 @@
package com.printcalculator.service.payment; package com.printcalculator.service;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import net.codecrete.qrbill.generator.Bill; import net.codecrete.qrbill.generator.Bill;
import net.codecrete.qrbill.generator.GraphicsFormat;
import net.codecrete.qrbill.generator.QRBill; import net.codecrete.qrbill.generator.QRBill;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service @Service
public class QrBillService { public class QrBillService {
@@ -19,7 +22,7 @@ public class QrBillService {
// Creditor (Merchant) // Creditor (Merchant)
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
bill.setCreditor(createAddress( bill.setCreditor(createAddress(
"Joe Küng", "Küng, Joe",
"Via G. Pioda 29a", "Via G. Pioda 29a",
"6710", "6710",
"Biasca", "Biasca",
@@ -46,7 +49,9 @@ public class QrBillService {
bill.setAmount(order.getTotalChf()); bill.setAmount(order.getTotalChf());
bill.setCurrency("CHF"); bill.setCurrency("CHF");
bill.setUnstructuredMessage(order.getId().toString()); // Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
bill.setUnstructuredMessage("Order " + order.getId());
return bill; return bill;
} }

View File

@@ -21,8 +21,6 @@ import java.util.List;
@Service @Service
public class QuoteCalculator { public class QuoteCalculator {
private static final BigDecimal SETUP_FEE_DOUBLE_THRESHOLD_CHF = BigDecimal.TEN;
private static final BigDecimal SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD = BigDecimal.valueOf(2);
private final PricingPolicyRepository pricingRepo; private final PricingPolicyRepository pricingRepo;
private final PricingPolicyMachineHourTierRepository tierRepo; private final PricingPolicyMachineHourTierRepository tierRepo;
@@ -62,70 +60,54 @@ public class QuoteCalculator {
.orElseThrow(() -> new RuntimeException("No active printer found")); .orElseThrow(() -> new RuntimeException("No active printer found"));
} }
// 3. Fetch Filament Info
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
// We try to extract material code (PLA, PETG)
String materialCode = detectMaterialCode(filamentProfileName); String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode) FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode)); .orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
// Try to find specific variant (e.g. by color if we could parse it)
// For now, get default/first active variant for this material
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode)); .orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
return calculate(stats, machine, policy, variant);
}
public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) { // --- CALCULATIONS ---
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) { // Material Cost: (weight / 1000) * costPerKg
throw new RuntimeException("No active pricing policy found"); // DISCOUNTED Support material to avoid penalizing users for default supports
BigDecimal weightToCharge;
if (stats.getModelWeightGrams() != null && stats.getSupportWeightGrams() != null) {
// Charge 100% for model + 20% for support
weightToCharge = BigDecimal.valueOf(stats.getModelWeightGrams())
.add(BigDecimal.valueOf(stats.getSupportWeightGrams()).multiply(BigDecimal.valueOf(0.2)));
} else {
weightToCharge = BigDecimal.valueOf(stats.getFilamentWeightGrams());
} }
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null); BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
if (machine == null) {
machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
return calculate(stats, machine, policy, variant);
}
private QuoteResult calculate(PrintStats stats, PrinterMachine machine, PricingPolicy policy, FilamentVariant variant) {
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams())
.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()) // Machine Cost: Tiered
.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()) // Energy Cost: (watts / 1000) * hours * costPerKwh
.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(totalHours); BigDecimal kwh = kw.multiply(totalHours);
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh()); BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
BigDecimal subtotal = materialCost.add(energyCost); // Subtotal (Costs + Fixed Fees)
BigDecimal markupFactor = BigDecimal.ONE.add( BigDecimal fixedFee = policy.getFixedJobFeeChf();
policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP) BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
);
subtotal = subtotal.multiply(markupFactor);
return new QuoteResult(subtotal.doubleValue(), "CHF", stats); // Markup
} // Markup is percentage (e.g. 20.0)
public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) {
BigDecimal rawCost = calculateMachineCost(policy, hours);
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal calculateSessionSetupFee(PricingPolicy policy) { return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
if (policy == null || policy.getFixedJobFeeChf() == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
BigDecimal baseSetupFee = policy.getFixedJobFeeChf();
if (baseSetupFee.compareTo(SETUP_FEE_DOUBLE_THRESHOLD_CHF) < 0) {
return baseSetupFee
.multiply(SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD)
.setScale(2, RoundingMode.HALF_UP);
}
return baseSetupFee.setScale(2, RoundingMode.HALF_UP);
} }
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) { private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
@@ -175,7 +157,6 @@ public class QuoteCalculator {
private String detectMaterialCode(String profileName) { private String detectMaterialCode(String profileName) {
String lower = profileName.toLowerCase(); String lower = profileName.toLowerCase();
if (lower.contains("pla tough") || lower.contains("pla_tough")) return "PLA TOUGH";
if (lower.contains("petg")) return "PETG"; if (lower.contains("petg")) return "PETG";
if (lower.contains("tpu")) return "TPU"; if (lower.contains("tpu")) return "TPU";
if (lower.contains("abs")) return "ABS"; if (lower.contains("abs")) return "ABS";

View File

@@ -1,167 +0,0 @@
package com.printcalculator.service;
import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashSet;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
@Service
public class QuoteSessionTotalsService {
private final PricingPolicyRepository pricingRepo;
private final QuoteCalculator quoteCalculator;
private final NozzleOptionRepository nozzleOptionRepo;
public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo,
QuoteCalculator quoteCalculator,
NozzleOptionRepository nozzleOptionRepo) {
this.pricingRepo = pricingRepo;
this.quoteCalculator = quoteCalculator;
this.nozzleOptionRepo = nozzleOptionRepo;
}
public QuoteSessionTotals compute(QuoteSession session, List<QuoteLineItem> items) {
BigDecimal printItemsBaseTotal = BigDecimal.ZERO;
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
int quantity = normalizeQuantity(item.getQuantity());
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
printItemsBaseTotal = printItemsBaseTotal.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
if (item.getPrintTimeSeconds() != null && item.getPrintTimeSeconds() > 0) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity)));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
BigDecimal printItemsTotal = printItemsBaseTotal.add(globalMachineCost);
BigDecimal cadTotal = calculateCadTotal(session);
BigDecimal itemsTotal = printItemsTotal.add(cadTotal);
BigDecimal baseSetupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal nozzleChangeCost = calculateNozzleChangeCost(items);
BigDecimal setupFee = baseSetupFee.add(nozzleChangeCost).setScale(2, RoundingMode.HALF_UP);
BigDecimal shippingCost = calculateShippingCost(items);
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost);
return new QuoteSessionTotals(
printItemsTotal,
globalMachineCost,
cadTotal,
itemsTotal,
baseSetupFee.setScale(2, RoundingMode.HALF_UP),
nozzleChangeCost,
setupFee,
shippingCost,
grandTotal,
totalSeconds
);
}
public BigDecimal calculateCadTotal(QuoteSession session) {
if (session == null) {
return BigDecimal.ZERO;
}
BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO;
BigDecimal cadRate = session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO;
if (cadHours.compareTo(BigDecimal.ZERO) <= 0 || cadRate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return cadHours.multiply(cadRate).setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal calculateShippingCost(List<QuoteLineItem> items) {
if (items == null || items.isEmpty()) {
return BigDecimal.ZERO;
}
boolean exceedsBaseSize = false;
for (QuoteLineItem item : items) {
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
BigDecimal[] dims = {x, y, z};
Arrays.sort(dims);
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0
|| dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0
|| dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
exceedsBaseSize = true;
break;
}
}
int totalQuantity = items.stream().mapToInt(i -> normalizeQuantity(i.getQuantity())).sum();
if (totalQuantity <= 0) {
return BigDecimal.ZERO;
}
if (exceedsBaseSize) {
return totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
}
return BigDecimal.valueOf(2.00);
}
private BigDecimal calculateNozzleChangeCost(List<QuoteLineItem> items) {
if (items == null || items.isEmpty()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
Set<BigDecimal> uniqueNozzles = new LinkedHashSet<>();
for (QuoteLineItem item : items) {
if (item == null || item.getNozzleDiameterMm() == null) {
continue;
}
uniqueNozzles.add(item.getNozzleDiameterMm().setScale(2, RoundingMode.HALF_UP));
}
BigDecimal totalFee = BigDecimal.ZERO;
for (BigDecimal nozzle : uniqueNozzles) {
BigDecimal nozzleFee = nozzleOptionRepo
.findFirstByNozzleDiameterMmAndIsActiveTrue(nozzle)
.map(option -> option.getExtraNozzleChangeFeeChf() != null
? option.getExtraNozzleChangeFeeChf()
: BigDecimal.ZERO)
.orElse(BigDecimal.ZERO);
if (nozzleFee.compareTo(BigDecimal.ZERO) > 0) {
totalFee = totalFee.add(nozzleFee);
}
}
return totalFee.setScale(2, RoundingMode.HALF_UP);
}
private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) {
return 1;
}
return quantity;
}
public record QuoteSessionTotals(
BigDecimal printItemsTotalChf,
BigDecimal globalMachineCostChf,
BigDecimal cadTotalChf,
BigDecimal itemsTotalChf,
BigDecimal baseSetupCostChf,
BigDecimal nozzleChangeCostChf,
BigDecimal setupCostChf,
BigDecimal shippingCostChf,
BigDecimal grandTotalChf,
BigDecimal totalPrintSeconds
) {}
}

View File

@@ -45,8 +45,9 @@ public class SessionCleanupService {
// "rimangono in memoria... cancella quelle vecchie di 7 giorni". // "rimangono in memoria... cancella quelle vecchie di 7 giorni".
// Implementation plan said: status != 'ORDERED'. // Implementation plan said: status != 'ORDERED'.
// CAD_ACTIVE sessions are managed manually from back-office and must be preserved. // User specified statuses: ACTIVE, EXPIRED, CONVERTED.
if ("CONVERTED".equals(session.getStatus()) || "CAD_ACTIVE".equals(session.getStatus())) { // We should NOT delete sessions that have been converted to an order.
if ("CONVERTED".equals(session.getStatus())) {
continue; continue;
} }

View File

@@ -0,0 +1,255 @@
package com.printcalculator.service;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
@Service
public class StlService {
public StlBounds readBounds(File stlFile) throws IOException {
long size = stlFile.length();
if (size >= 84 && isBinaryStl(stlFile, size)) {
return readBinaryBounds(stlFile);
}
return readAsciiBounds(stlFile);
}
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
int bedX, int bedY, int bedZ) throws IOException {
double sizeX = bounds.sizeX();
double sizeY = bounds.sizeY();
double sizeZ = bounds.sizeZ();
double targetMinX = (bedX - sizeX) / 2.0;
double targetMinY = (bedY - sizeY) / 2.0;
double targetMinZ = 0.0;
double offsetX = targetMinX - bounds.minX();
double offsetY = targetMinY - bounds.minY();
double offsetZ = targetMinZ - bounds.minZ();
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
if (!needsShift) {
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
}
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
}
private boolean isBinaryStl(File stlFile, long size) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
long expected = 84L + triangleCount * 50L;
return expected == size;
}
}
private StlBounds readBinaryBounds(File stlFile) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
raf.seek(84);
BoundsAccumulator acc = new BoundsAccumulator();
for (long i = 0; i < triangleCount; i++) {
// skip normal
readLEFloat(raf);
readLEFloat(raf);
readLEFloat(raf);
// 3 vertices
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
// skip attribute byte count
raf.skipBytes(2);
}
return acc.toBounds();
}
}
private StlBounds readAsciiBounds(File stlFile) throws IOException {
BoundsAccumulator acc = new BoundsAccumulator();
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (!line.startsWith("vertex")) continue;
String[] parts = line.split("\\s+");
if (parts.length < 4) continue;
double x = Double.parseDouble(parts[1]);
double y = Double.parseDouble(parts[2]);
double z = Double.parseDouble(parts[3]);
acc.accept(x, y, z);
}
}
return acc.toBounds();
}
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
long size = input.length();
if (size >= 84 && isBinaryStl(input, size)) {
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
} else {
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
}
}
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.startsWith("vertex")) {
writer.write(line);
writer.newLine();
continue;
}
String[] parts = trimmed.split("\\s+");
if (parts.length < 4) {
writer.write(line);
writer.newLine();
continue;
}
double x = Double.parseDouble(parts[1]) + offsetX;
double y = Double.parseDouble(parts[2]) + offsetY;
double z = Double.parseDouble(parts[3]) + offsetZ;
int idx = line.indexOf("vertex");
String indent = idx > 0 ? line.substring(0, idx) : "";
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
writer.newLine();
}
}
}
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
OutputStream out = new FileOutputStream(output)) {
byte[] header = new byte[80];
raf.readFully(header);
out.write(header);
long triangleCount = readLEUInt32(raf);
writeLEUInt32(out, triangleCount);
for (long i = 0; i < triangleCount; i++) {
// normal
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
// vertices
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
// attribute byte count
int b1 = raf.read();
int b2 = raf.read();
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
out.write(b1);
out.write(b2);
}
}
}
private long readLEUInt32(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return ((long) b1 & 0xFF)
| (((long) b2 & 0xFF) << 8)
| (((long) b3 & 0xFF) << 16)
| (((long) b4 & 0xFF) << 24);
}
private int readLEInt(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return (b1 & 0xFF)
| ((b2 & 0xFF) << 8)
| ((b3 & 0xFF) << 16)
| ((b4 & 0xFF) << 24);
}
private float readLEFloat(RandomAccessFile raf) throws IOException {
return Float.intBitsToFloat(readLEInt(raf));
}
private void writeLEUInt32(OutputStream out, long value) throws IOException {
out.write((int) (value & 0xFF));
out.write((int) ((value >> 8) & 0xFF));
out.write((int) ((value >> 16) & 0xFF));
out.write((int) ((value >> 24) & 0xFF));
}
private void writeLEFloat(OutputStream out, float value) throws IOException {
int bits = Float.floatToIntBits(value);
out.write(bits & 0xFF);
out.write((bits >> 8) & 0xFF);
out.write((bits >> 16) & 0xFF);
out.write((bits >> 24) & 0xFF);
}
private static class BoundsAccumulator {
private boolean hasPoint = false;
private double minX;
private double minY;
private double minZ;
private double maxX;
private double maxY;
private double maxZ;
void accept(double x, double y, double z) {
if (!hasPoint) {
minX = maxX = x;
minY = maxY = y;
minZ = maxZ = z;
hasPoint = true;
return;
}
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
StlBounds toBounds() throws IOException {
if (!hasPoint) {
throw new IOException("STL appears to contain no vertices");
}
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
}
}

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service.storage; package com.printcalculator.service;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;

View File

@@ -1,327 +0,0 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminFilamentControllerService {
private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999");
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final Set<String> ALLOWED_FINISH_TYPES = Set.of(
"GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL"
);
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentControllerService(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
}
public List<AdminFilamentMaterialTypeDto> getMaterials() {
return materialRepo.findAll().stream()
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
}
public List<AdminFilamentVariantDto> getVariants() {
return variantRepo.findAll().stream()
.sorted(Comparator
.comparing((FilamentVariant variant) -> {
FilamentMaterialType type = variant.getFilamentMaterialType();
return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : "";
}, String.CASE_INSENSITIVE_ORDER)
.thenComparing(variant -> variant.getVariantDisplayName() != null ? variant.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER))
.map(this::toVariantDto)
.toList();
}
@Transactional
public AdminFilamentMaterialTypeDto createMaterial(AdminUpsertFilamentMaterialTypeRequest payload) {
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return toMaterialDto(saved);
}
@Transactional
public AdminFilamentMaterialTypeDto updateMaterial(Long materialTypeId, AdminUpsertFilamentMaterialTypeRequest payload) {
FilamentMaterialType material = materialRepo.findById(materialTypeId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found"));
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, materialTypeId);
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return toMaterialDto(saved);
}
@Transactional
public AdminFilamentVariantDto createVariant(AdminUpsertFilamentVariantRequest payload) {
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null);
FilamentVariant variant = new FilamentVariant();
variant.setCreatedAt(OffsetDateTime.now());
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return toVariantDto(saved);
}
@Transactional
public AdminFilamentVariantDto updateVariant(Long variantId, AdminUpsertFilamentVariantRequest payload) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId);
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return toVariantDto(saved);
}
@Transactional
public void deleteVariant(Long variantId) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) {
throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted");
}
variantRepo.delete(variant);
}
private void applyMaterialPayload(FilamentMaterialType material,
AdminUpsertFilamentMaterialTypeRequest payload,
String normalizedMaterialCode) {
boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible());
boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical());
String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null
? payload.getTechnicalTypeLabel().trim()
: null;
material.setMaterialCode(normalizedMaterialCode);
material.setIsFlexible(isFlexible);
material.setIsTechnical(isTechnical);
material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank()
? technicalTypeLabel
: null);
}
private void applyVariantPayload(FilamentVariant variant,
AdminUpsertFilamentVariantRequest payload,
FilamentMaterialType material,
String normalizedDisplayName,
String normalizedColorName) {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand());
variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName);
variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand);
variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType));
variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial()));
variant.setCostChfPerKg(payload.getCostChfPerKg());
variant.setStockSpools(payload.getStockSpools());
variant.setSpoolNetKg(payload.getSpoolNetKg());
variant.setIsActive(payload.getIsActive() == null || payload.getIsActive());
}
private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) {
if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Material code is required");
}
return payload.getMaterialCode().trim().toUpperCase(Locale.ROOT);
}
private String normalizeAndValidateVariantDisplayName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required");
}
return value.trim();
}
private String normalizeAndValidateColorName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Color name is required");
}
return value.trim();
}
private String normalizeAndValidateColorHex(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) {
throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB");
}
return normalized.toUpperCase(Locale.ROOT);
}
private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) {
String normalized = finishType == null || finishType.isBlank()
? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY")
: finishType.trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_FINISH_TYPES.contains(normalized)) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type");
}
return normalized;
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
}
return materialRepo.findById(payload.getMaterialTypeId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found"));
}
private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) {
if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0");
}
validateNumeric63(payload.getStockSpools(), "Stock spools", true);
validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false);
}
private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) {
if (value == null) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required");
}
if (allowZero) {
if (value.compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0");
}
} else if (value.compareTo(BigDecimal.ZERO) <= 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0");
}
if (value.scale() > 3) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places");
}
if (value.compareTo(MAX_NUMERIC_6_3) > 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999");
}
}
private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) {
materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> {
if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) {
throw new ResponseStatusException(BAD_REQUEST, "Material code already exists");
}
});
}
private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) {
variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> {
if (currentVariantId == null || !existing.getId().equals(currentVariantId)) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material");
}
});
}
private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) {
AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto();
dto.setId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setIsFlexible(material.getIsFlexible());
dto.setIsTechnical(material.getIsTechnical());
dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel());
return dto;
}
private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) {
AdminFilamentVariantDto dto = new AdminFilamentVariantDto();
dto.setId(variant.getId());
FilamentMaterialType material = variant.getFilamentMaterialType();
if (material != null) {
dto.setMaterialTypeId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setMaterialIsFlexible(material.getIsFlexible());
dto.setMaterialIsTechnical(material.getIsTechnical());
dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel());
}
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand());
dto.setIsMatte(variant.getIsMatte());
dto.setIsSpecial(variant.getIsSpecial());
dto.setCostChfPerKg(variant.getCostChfPerKg());
dto.setStockSpools(variant.getStockSpools());
dto.setSpoolNetKg(variant.getSpoolNetKg());
BigDecimal stockKg = BigDecimal.ZERO;
if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) {
stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg());
}
dto.setStockKg(stockKg);
dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000)));
dto.setIsActive(variant.getIsActive());
dto.setCreatedAt(variant.getCreatedAt());
return dto;
}
}

View File

@@ -1,469 +0,0 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.FilamentVariantStockKgRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteSessionTotalsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminOperationsControllerService {
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsControllerService.class);
private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Set<String> CONTACT_REQUEST_ALLOWED_STATUSES = Set.of(
"NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED"
);
private final FilamentVariantStockKgRepository filamentStockRepo;
private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderRepository orderRepo;
private final PricingPolicyRepository pricingRepo;
private final QuoteSessionTotalsService quoteSessionTotalsService;
public AdminOperationsControllerService(FilamentVariantStockKgRepository filamentStockRepo,
FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo,
CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderRepository orderRepo,
PricingPolicyRepository pricingRepo,
QuoteSessionTotalsService quoteSessionTotalsService) {
this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo;
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderRepo = orderRepo;
this.pricingRepo = pricingRepo;
this.quoteSessionTotalsService = quoteSessionTotalsService;
}
public List<AdminFilamentStockDto> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg"));
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> variantsById;
if (variantIds.isEmpty()) {
variantsById = Collections.emptyMap();
} else {
variantsById = filamentVariantRepo.findAllById(variantIds).stream()
.collect(Collectors.toMap(FilamentVariant::getId, variant -> variant));
}
return stocks.stream().map(stock -> {
FilamentVariant variant = variantsById.get(stock.getFilamentVariantId());
AdminFilamentStockDto dto = new AdminFilamentStockDto();
dto.setFilamentVariantId(stock.getFilamentVariantId());
dto.setStockSpools(stock.getStockSpools());
dto.setSpoolNetKg(stock.getSpoolNetKg());
dto.setStockKg(stock.getStockKg());
BigDecimal grams = stock.getStockKg() != null
? stock.getStockKg().multiply(BigDecimal.valueOf(1000))
: BigDecimal.ZERO;
dto.setStockFilamentGrams(grams);
if (variant != null) {
dto.setMaterialCode(
variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: "UNKNOWN"
);
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setActive(variant.getIsActive());
} else {
dto.setMaterialCode("UNKNOWN");
dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId());
dto.setColorName("-");
dto.setActive(false);
}
return dto;
}).toList();
}
public List<AdminContactRequestDto> getContactRequests() {
return customQuoteRequestRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt"))
.stream()
.map(this::toContactRequestDto)
.toList();
}
public AdminContactRequestDetailDto getContactRequestDetail(UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return toContactRequestDetailDto(request, attachments);
}
@Transactional
public AdminContactRequestDetailDto updateContactRequestStatus(UUID requestId,
AdminUpdateContactRequestStatusRequest payload) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
String requestedStatus = payload != null && payload.getStatus() != null
? payload.getStatus().trim().toUpperCase(Locale.ROOT)
: "";
if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES)
);
}
request.setStatus(requestedStatus);
request.setUpdatedAt(OffsetDateTime.now());
CustomQuoteRequest saved = customQuoteRequestRepo.save(request);
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return toContactRequestDetailDto(saved, attachments);
}
public ResponseEntity<Resource> downloadContactRequestAttachment(UUID requestId, UUID attachmentId) {
CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found"));
if (!attachment.getRequest().getId().equals(requestId)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request");
}
String relativePath = attachment.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/";
if (!relativePath.startsWith(expectedPrefix)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize();
if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
if (!Files.exists(filePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
try {
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
String mimeType = attachment.getMimeType();
if (mimeType != null && !mimeType.isBlank()) {
try {
mediaType = MediaType.parseMediaType(mimeType);
} catch (Exception ignored) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = attachment.getOriginalFilename();
if (filename == null || filename.isBlank()) {
filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank()
? attachment.getStoredFilename()
: "attachment-" + attachmentId;
}
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (MalformedURLException e) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
}
public List<AdminQuoteSessionDto> getQuoteSessions() {
return quoteSessionRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt"))
.stream()
.map(this::toQuoteSessionDto)
.toList();
}
public List<AdminCadInvoiceDto> getCadInvoices() {
return quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED"))
.stream()
.filter(this::isCadSessionRecord)
.map(this::toCadInvoiceDto)
.toList();
}
@Transactional
public AdminCadInvoiceDto createOrUpdateCadInvoice(AdminCadInvoiceCreateRequest payload) {
if (payload == null || payload.getCadHours() == null) {
throw new ResponseStatusException(BAD_REQUEST, "cadHours is required");
}
BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP);
if (cadHours.compareTo(BigDecimal.ZERO) <= 0) {
throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0");
}
BigDecimal cadRate = payload.getCadHourlyRateChf();
if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) {
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
cadRate = policy != null && policy.getCadCostChfPerHour() != null
? policy.getCadCostChfPerHour()
: BigDecimal.ZERO;
}
cadRate = cadRate.setScale(2, RoundingMode.HALF_UP);
QuoteSession session;
if (payload.getSessionId() != null) {
session = quoteSessionRepo.findById(payload.getSessionId())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found"));
} else {
session = new QuoteSession();
session.setStatus("CAD_ACTIVE");
session.setPricingVersion("v1");
session.setMaterialCode("PLA");
session.setNozzleDiameterMm(BigDecimal.valueOf(0.4));
session.setLayerHeightMm(BigDecimal.valueOf(0.2));
session.setInfillPattern("grid");
session.setInfillPercent(20);
session.setSupportsEnabled(false);
session.setSetupCostChf(BigDecimal.ZERO);
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
}
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(CONFLICT, "Session already converted to order");
}
if (payload.getSourceRequestId() != null) {
if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) {
throw new ResponseStatusException(NOT_FOUND, "Source request not found");
}
session.setSourceRequestId(payload.getSourceRequestId());
} else {
session.setSourceRequestId(null);
}
session.setStatus("CAD_ACTIVE");
session.setCadHours(cadHours);
session.setCadHourlyRateChf(cadRate);
if (payload.getNotes() != null) {
String trimmedNotes = payload.getNotes().trim();
session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes);
}
QuoteSession saved = quoteSessionRepo.save(session);
return toCadInvoiceDto(saved);
}
@Transactional
public void deleteQuoteSession(UUID sessionId) {
QuoteSession session = quoteSessionRepo.findById(sessionId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found"));
if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) {
throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order");
}
deleteSessionFiles(sessionId);
quoteSessionRepo.delete(session);
}
private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) {
AdminContactRequestDto dto = new AdminContactRequestDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
return dto;
}
private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) {
AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto();
dto.setId(attachment.getId());
dto.setOriginalFilename(attachment.getOriginalFilename());
dto.setMimeType(attachment.getMimeType());
dto.setFileSizeBytes(attachment.getFileSizeBytes());
dto.setCreatedAt(attachment.getCreatedAt());
return dto;
}
private AdminContactRequestDetailDto toContactRequestDetailDto(CustomQuoteRequest request,
List<AdminContactRequestAttachmentDto> attachments) {
AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setContactPerson(request.getContactPerson());
dto.setMessage(request.getMessage());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
dto.setUpdatedAt(request.getUpdatedAt());
dto.setAttachments(attachments);
return dto;
}
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId());
dto.setStatus(session.getStatus());
dto.setMaterialCode(session.getMaterialCode());
dto.setCreatedAt(session.getCreatedAt());
dto.setExpiresAt(session.getExpiresAt());
dto.setConvertedOrderId(session.getConvertedOrderId());
dto.setSourceRequestId(session.getSourceRequestId());
dto.setCadHours(session.getCadHours());
dto.setCadHourlyRateChf(session.getCadHourlyRateChf());
dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session));
return dto;
}
private boolean isCadSessionRecord(QuoteSession session) {
if ("CAD_ACTIVE".equals(session.getStatus())) {
return true;
}
if (!"CONVERTED".equals(session.getStatus())) {
return false;
}
BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO;
return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null;
}
private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) {
List<QuoteLineItem> items = quoteLineItemRepo.findByQuoteSessionId(session.getId());
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
AdminCadInvoiceDto dto = new AdminCadInvoiceDto();
dto.setSessionId(session.getId());
dto.setSessionStatus(session.getStatus());
dto.setSourceRequestId(session.getSourceRequestId());
dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO);
dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO);
dto.setCadTotalChf(totals.cadTotalChf());
dto.setPrintItemsTotalChf(totals.printItemsTotalChf());
dto.setSetupCostChf(totals.setupCostChf());
dto.setShippingCostChf(totals.shippingCostChf());
dto.setGrandTotalChf(totals.grandTotalChf());
dto.setConvertedOrderId(session.getConvertedOrderId());
dto.setCheckoutPath("/checkout/cad?session=" + session.getId());
dto.setNotes(session.getNotes());
dto.setCreatedAt(session.getCreatedAt());
if (session.getConvertedOrderId() != null) {
Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null);
dto.setConvertedOrderStatus(order != null ? order.getStatus() : null);
}
return dto;
}
private void deleteSessionFiles(UUID sessionId) {
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
if (!Files.exists(sessionDir)) {
return;
}
try (Stream<Path> walk = Files.walk(sessionDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException | UncheckedIOException e) {
logger.error("Failed to delete files for session {}", sessionId, e);
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files");
}
}
}

View File

@@ -1,29 +0,0 @@
package com.printcalculator.service.email;
import java.util.Map;
public interface EmailNotificationService {
/**
* Sends an HTML email using a Thymeleaf template.
*
* @param to The recipient email address.
* @param subject The subject of the email.
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
* @param contextData The data to populate the template with.
*/
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
/**
* Sends an HTML email using a Thymeleaf template, with an optional attachment.
*
* @param to The recipient email address.
* @param subject The subject of the email.
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
* @param contextData The data to populate the template with.
* @param attachmentName The name for the attachment file.
* @param attachmentData The raw bytes of the attachment.
*/
void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData);
}

View File

@@ -1,72 +0,0 @@
package com.printcalculator.service.email;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.springframework.core.io.ByteArrayResource;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmtpEmailNotificationService implements EmailNotificationService {
private final JavaMailSender emailSender;
private final TemplateEngine templateEngine;
@Value("${app.mail.from}")
private String fromAddress;
@Value("${app.mail.enabled:true}")
private boolean mailEnabled;
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
sendEmailWithAttachment(to, subject, templateName, contextData, null, null);
}
@Override
public void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData) {
if (!mailEnabled) {
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
return;
}
log.info("Preparing to send email to {} with template {}", to, templateName);
try {
Context context = new Context();
context.setVariables(contextData);
String process = templateEngine.process("email/" + templateName, context);
MimeMessage mimeMessage = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(process, true); // true indicates HTML format
if (attachmentName != null && attachmentData != null) {
helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData));
}
emailSender.send(mimeMessage);
log.info("Email successfully sent to {}", to);
} catch (MessagingException e) {
log.error("Failed to send email to {}", to, e);
// Non blocco l'ordine se l'email fallisce, ma loggo l'errore adeguatamente.
} catch (Exception e) {
log.error("Unexpected error while sending email to {}", to, e);
}
}
}

View File

@@ -1,435 +0,0 @@
package com.printcalculator.service.order;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminOrderControllerService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public AdminOrderControllerService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
QuoteLineItemRepository quoteLineItemRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
}
public List<OrderDto> listOrders() {
return orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
}
public OrderDto getOrder(UUID orderId) {
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updatePaymentMethod(UUID orderId, Map<String, String> payload) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
if (method == null || method.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Payment method is required");
}
paymentService.updatePaymentMethod(orderId, method);
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updateOrderStatus(UUID orderId, AdminOrderStatusUpdateRequest payload) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
String previousStatus = order.getStatus();
order.setStatus(normalizedStatus);
Order savedOrder = orderRepo.save(order);
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
}
return toOrderDto(savedOrder);
}
public ResponseEntity<Resource> downloadOrderItemFile(UUID orderId, UUID orderItemId) {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
public ResponseEntity<byte[]> downloadOrderConfirmation(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
public ResponseEntity<byte[]> downloadOrderInvoice(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
dto.setPaymentStatus(payment.getStatus());
dto.setPaymentMethod(payment.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId());
itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode());
if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
}
itemDto.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm());
itemDto.setInfillPercent(item.getInfillPercent());
itemDto.setInfillPattern(item.getInfillPattern());
itemDto.setSupportsEnabled(item.getSupportsEnabled());
itemDto.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams());
itemDto.setUnitPriceChf(item.getUnitPriceChf());
itemDto.setLineTotalChf(item.getLineTotalChf());
return itemDto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) {
try {
return storageService.loadAsResource(safeRelativePath);
} catch (Exception primaryFailure) {
Path sourceQuotePath = resolveFallbackQuoteItemPath(item);
if (sourceQuotePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
storageService.store(sourceQuotePath, safeRelativePath);
return storageService.loadAsResource(safeRelativePath);
} catch (Exception copyFailure) {
try {
Resource quoteResource = new UrlResource(sourceQuotePath.toUri());
if (quoteResource.exists() || quoteResource.isReadable()) {
return quoteResource;
}
} catch (Exception ignored) {
// fall through to 404
}
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
}
private Path resolveFallbackQuoteItemPath(OrderItem orderItem) {
Order order = orderItem.getOrder();
QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null;
UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null;
if (sourceSessionId == null) {
return null;
}
String targetFilename = normalizeFilename(orderItem.getOriginalFilename());
if (targetFilename == null) {
return null;
}
return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream()
.filter(quoteItem -> targetFilename.equals(normalizeFilename(quoteItem.getOriginalFilename())))
.sorted(Comparator.comparingInt((QuoteLineItem quoteItem) -> scoreQuoteMatch(orderItem, quoteItem)).reversed())
.map(quoteItem -> resolveStoredQuotePath(quoteItem.getStoredPath(), sourceSessionId))
.filter(path -> path != null && Files.exists(path))
.findFirst()
.orElse(null);
}
private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) {
int score = 0;
if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) {
score += 4;
}
if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) {
score += 3;
}
if (orderItem.getMaterialCode() != null
&& quoteItem.getMaterialCode() != null
&& orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) {
score += 3;
}
if (orderItem.getMaterialGrams() != null
&& quoteItem.getMaterialGrams() != null
&& orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) {
score += 2;
}
return score;
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return null;
}
return filename.trim();
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
}

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