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
276 changed files with 5338 additions and 23996 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,42 +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: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
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
@@ -141,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 }}"
@@ -182,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,172 +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: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
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

@@ -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,108 +1,48 @@
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.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
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.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
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.nio.file.StandardCopyOption;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.Year; import java.util.ArrayList;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}") private final com.printcalculator.service.StorageService storageService;
private boolean contactRequestAdminMailEnabled;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
@Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled;
// TODO: Inject Storage Service
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of(
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-7z-compressed",
"application/gzip",
"application/x-gzip",
"application/x-tar",
"application/x-bzip2",
"application/x-xz",
"application/zstd",
"application/x-zstd"
);
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
ClamAVService clamAVService, com.printcalculator.service.StorageService storageService) {
EmailNotificationService emailNotificationService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService; this.storageService = storageService;
this.emailNotificationService = emailNotificationService;
} }
// 1. Create Custom Quote Request // 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 {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Accettazione Termini e Privacy obbligatoria."
);
}
String language = normalizeLanguage(requestDto.getLanguage());
// 1. Create Request // 1. Create Request
CustomQuoteRequest request = new CustomQuoteRequest(); CustomQuoteRequest request = new CustomQuoteRequest();
@@ -121,7 +61,6 @@ public class CustomQuoteRequestController {
request = requestRepo.save(request); request = requestRepo.save(request);
// 2. Handle Attachments // 2. Handle Attachments
int attachmentsCount = 0;
if (files != null && !files.isEmpty()) { if (files != null && !files.isEmpty()) {
if (files.size() > 15) { if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed."); throw new IOException("Too many files. Max 15 allowed.");
@@ -130,16 +69,6 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) { for (MultipartFile file : files) {
if (file.isEmpty()) continue; if (file.isEmpty()) continue;
if (isCompressedFile(file)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Compressed files are not allowed."
);
}
// Scan for virus
clamAVService.scan(file.getInputStream());
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request); attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename()); attachment.setOriginalFilename(file.getOriginalFilename());
@@ -149,7 +78,8 @@ public class CustomQuoteRequestController {
// Generate path // Generate path
UUID fileUuid = UUID.randomUUID(); UUID fileUuid = UUID.randomUUID();
String storedFilename = fileUuid + ".upload"; String ext = getExtension(file.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext;
// Note: We don't have attachment ID yet. // Note: We don't have attachment ID yet.
// We'll save attachment first to get ID. // We'll save attachment first to get ID.
@@ -158,29 +88,15 @@ public class CustomQuoteRequestController {
attachment = attachmentRepo.save(attachment); attachment = attachmentRepo.save(attachment);
Path relativePath = Path.of( String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename;
"quote-requests", attachment.setStoredRelativePath(relativePath);
request.getId().toString(),
"attachments",
attachment.getId().toString(),
storedFilename
);
attachment.setStoredRelativePath(relativePath.toString());
attachmentRepo.save(attachment); attachmentRepo.save(attachment);
// Save file to disk // Save file to disk via StorageService
Path absolutePath = resolveWithinStorageRoot(relativePath); storageService.store(file, Paths.get(relativePath));
Files.createDirectories(absolutePath.getParent());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
}
attachmentsCount++;
} }
} }
sendAdminContactRequestNotification(request, attachmentsCount);
sendCustomerContactRequestConfirmation(request, attachmentsCount, language);
return ResponseEntity.ok(request); return ResponseEntity.ok(request);
} }
@@ -195,326 +111,10 @@ public class CustomQuoteRequestController {
// Helper // Helper
private String getExtension(String filename) { private String getExtension(String filename) {
if (filename == null) return "dat"; if (filename == null) return "dat";
String cleaned = StringUtils.cleanPath(filename); int i = filename.lastIndexOf('.');
if (cleaned.contains("..")) { if (i > 0) {
return "dat"; return filename.substring(i + 1);
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
} }
return "dat"; return "dat";
} }
private boolean isCompressedFile(MultipartFile file) {
String ext = getExtension(file.getOriginalFilename());
if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) {
return true;
}
String mime = file.getContentType();
return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase());
}
private Path resolveWithinStorageRoot(Path relativePath) {
try {
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize();
if (!absolutePath.startsWith(STORAGE_ROOT)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
return absolutePath;
} catch (InvalidPathException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
}
private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
if (!contactRequestAdminMailEnabled) {
return;
}
if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
logger.warn("Contact request admin notification enabled but no admin address configured.");
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put("createdAt", request.getCreatedAt());
templateData.put("requestType", safeValue(request.getRequestType()));
templateData.put("customerType", safeValue(request.getCustomerType()));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail(
contactRequestAdminMailAddress,
"Nuova richiesta di contatto #" + request.getId(),
"contact-request-admin",
templateData
);
}
private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) {
if (!contactRequestCustomerMailEnabled) {
return;
}
if (request.getEmail() == null || request.getEmail().isBlank()) {
logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId());
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put(
"createdAt",
request.getCreatedAt().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language))
)
);
templateData.put("recipientName", resolveRecipientName(request, language));
templateData.put("requestType", localizeRequestType(request.getRequestType(), language));
templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
String subject = applyCustomerContactRequestTexts(templateData, language, request.getId());
emailNotificationService.sendEmail(
request.getEmail(),
subject,
"contact-request-customer",
templateData
);
}
private String applyCustomerContactRequestTexts(
Map<String, Object> templateData,
String language,
UUID requestId
) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Contact request received");
templateData.put("headlineText", "We received your contact request");
templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ",");
templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible.");
templateData.put("requestIdHintText", "Please keep this request ID for future order references:");
templateData.put("detailsTitleText", "Request details");
templateData.put("labelRequestId", "Request ID");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Request type");
templateData.put("labelCustomerType", "Customer type");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Company");
templateData.put("labelContactPerson", "Contact person");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Phone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Attachments");
templateData.put("supportText", "If you need help, reply to this email.");
templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab.");
yield "We received your contact request #" + requestId + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Kontaktanfrage erhalten");
templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten");
templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ",");
templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich.");
templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:");
templateData.put("detailsTitleText", "Anfragedetails");
templateData.put("labelRequestId", "Anfrage-ID");
templateData.put("labelDate", "Datum");
templateData.put("labelRequestType", "Anfragetyp");
templateData.put("labelCustomerType", "Kundentyp");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Firma");
templateData.put("labelContactPerson", "Kontaktperson");
templateData.put("labelEmail", "E-Mail");
templateData.put("labelPhone", "Telefon");
templateData.put("labelMessage", "Nachricht");
templateData.put("labelAttachments", "Anhaenge");
templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab.");
yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Demande de contact recue");
templateData.put("headlineText", "Nous avons recu votre demande de contact");
templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ",");
templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible.");
templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :");
templateData.put("detailsTitleText", "Details de la demande");
templateData.put("labelRequestId", "ID de demande");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Type de demande");
templateData.put("labelCustomerType", "Type de client");
templateData.put("labelName", "Nom");
templateData.put("labelCompany", "Entreprise");
templateData.put("labelContactPerson", "Contact");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telephone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Pieces jointes");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab.");
yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Richiesta di contatto ricevuta");
templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto");
templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ",");
templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile.");
templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:");
templateData.put("detailsTitleText", "Dettagli richiesta");
templateData.put("labelRequestId", "ID richiesta");
templateData.put("labelDate", "Data");
templateData.put("labelRequestType", "Tipo richiesta");
templateData.put("labelCustomerType", "Tipo cliente");
templateData.put("labelName", "Nome");
templateData.put("labelCompany", "Azienda");
templateData.put("labelContactPerson", "Contatto");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telefono");
templateData.put("labelMessage", "Messaggio");
templateData.put("labelAttachments", "Allegati");
templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email.");
templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab.");
yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab";
}
};
}
private String localizeRequestType(String requestType, String language) {
if (requestType == null || requestType.isBlank()) {
return "-";
}
String normalized = requestType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "custom", "print_service" -> "Custom part request";
case "series" -> "Series production request";
case "consult", "design_service" -> "Consultation request";
case "question" -> "General question";
default -> requestType;
};
case "de" -> switch (normalized) {
case "custom", "print_service" -> "Anfrage fuer Einzelteil";
case "series" -> "Anfrage fuer Serienproduktion";
case "consult", "design_service" -> "Beratungsanfrage";
case "question" -> "Allgemeine Frage";
default -> requestType;
};
case "fr" -> switch (normalized) {
case "custom", "print_service" -> "Demande de piece personnalisee";
case "series" -> "Demande de production en serie";
case "consult", "design_service" -> "Demande de conseil";
case "question" -> "Question generale";
default -> requestType;
};
default -> switch (normalized) {
case "custom", "print_service" -> "Richiesta pezzo personalizzato";
case "series" -> "Richiesta produzione in serie";
case "consult", "design_service" -> "Richiesta consulenza";
case "question" -> "Domanda generale";
default -> requestType;
};
};
}
private String localizeCustomerType(String customerType, String language) {
if (customerType == null || customerType.isBlank()) {
return "-";
}
String normalized = customerType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "private" -> "Private";
case "business" -> "Business";
default -> customerType;
};
case "de" -> switch (normalized) {
case "private" -> "Privat";
case "business" -> "Unternehmen";
default -> customerType;
};
case "fr" -> switch (normalized) {
case "private" -> "Prive";
case "business" -> "Entreprise";
default -> customerType;
};
default -> switch (normalized) {
case "private" -> "Privato";
case "business" -> "Azienda";
default -> customerType;
};
};
}
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 normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.toLowerCase(Locale.ROOT).trim();
if (normalized.startsWith("en")) {
return "en";
}
if (normalized.startsWith("de")) {
return "de";
}
if (normalized.startsWith("fr")) {
return "fr";
}
return "it";
}
private String resolveRecipientName(CustomQuoteRequest request, String language) {
if (request.getName() != null && !request.getName().isBlank()) {
return request.getName().trim();
}
if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
return request.getContactPerson().trim();
}
if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) {
return request.getCompanyName().trim();
}
return switch (language) {
case "en" -> "customer";
case "de" -> "Kunde";
case "fr" -> "client";
default -> "cliente";
};
}
private String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
} }

View File

@@ -3,28 +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.LayerHeightOption; import com.printcalculator.entity.*; // This line replaces specific entity imports
import com.printcalculator.entity.MaterialOrcaProfileMap;
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.LayerHeightOptionRepository; import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.OrcaProfileResolver;
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.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -34,99 +24,88 @@ public class OptionsController {
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo; private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo, LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo, NozzleOptionRepository nozzleRepo) {
PrinterMachineRepository printerMachineRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo; this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
} }
@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")
); );
// 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream() List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> Boolean.TRUE.equals(l.getIsActive())) .filter(l -> l.getIsActive())
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO( .map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(), l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm()) String.format("%.2f mm", l.getLayerHeightMm())
)) ))
.toList(); .collect(Collectors.toList());
// 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())
.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(),
@@ -135,76 +114,13 @@ public class OptionsController {
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)") : " (Standard)")
)) ))
.toList(); .collect(Collectors.toList());
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
} }
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { // Temporary helper until we add hex to DB
PrinterMachine machine = null;
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
if (machine == null) {
return Set.of();
}
BigDecimal nozzle = nozzleDiameter != null
? BigDecimal.valueOf(nozzleDiameter)
: BigDecimal.valueOf(0.40);
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 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";
@@ -218,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

@@ -5,37 +5,30 @@ import com.printcalculator.entity.*;
import com.printcalculator.repository.*; import com.printcalculator.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.StorageService;
import com.printcalculator.service.TwintPaymentService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
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 jakarta.validation.Valid;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.InvalidPathException; import java.nio.file.Files;
import java.nio.file.Path; 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.List;
import java.util.UUID; import java.util.UUID;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.Base64;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.net.URI;
import java.util.Locale;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private final OrderService orderService; private final OrderService orderService;
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
@@ -46,9 +39,6 @@ public class OrderController {
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 TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderController(OrderService orderService, public OrderController(OrderService orderService,
@@ -59,10 +49,7 @@ public class OrderController {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService) {
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService; this.orderService = orderService;
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
@@ -72,9 +59,6 @@ public class OrderController {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
} }
@@ -83,7 +67,7 @@ public class OrderController {
@Transactional @Transactional
public ResponseEntity<OrderDto> createOrderFromQuote( public ResponseEntity<OrderDto> createOrderFromQuote(
@PathVariable UUID quoteSessionId, @PathVariable UUID quoteSessionId,
@Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request @RequestBody com.printcalculator.dto.CreateOrderRequest request
) { ) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request); Order order = orderService.createOrderFromQuote(quoteSessionId, request);
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId()); List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
@@ -106,21 +90,15 @@ public class OrderController {
} }
String relativePath = item.getStoredRelativePath(); String relativePath = item.getStoredRelativePath();
Path destinationRelativePath;
if (relativePath == null || relativePath.equals("PENDING")) { if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename()); String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID() + "." + ext; String storedFilename = UUID.randomUUID().toString() + "." + ext;
destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
item.setStoredRelativePath(destinationRelativePath.toString()); item.setStoredRelativePath(relativePath);
item.setStoredFilename(storedFilename); item.setStoredFilename(storedFilename);
} else {
destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (destinationRelativePath == null) {
return ResponseEntity.badRequest().build();
}
} }
storageService.store(file, destinationRelativePath); storageService.store(file, Paths.get(relativePath));
item.setFileSizeBytes(file.getSize()); item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType()); item.setMimeType(file.getContentType());
orderItemRepo.save(item); orderItemRepo.save(item);
@@ -138,174 +116,97 @@ public class OrderController {
.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
) {
String method = payload.get("method");
paymentService.reportPayment(orderId, method);
return getOrder(orderId);
}
@GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return generateDocument(orderId, true);
}
@GetMapping("/{orderId}/invoice") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
// Paid invoices are sent by email after back-office payment confirmation.
// The public endpoint must not expose a "paid" invoice download.
return ResponseEntity.notFound().build();
}
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
Order order = orderRepo.findById(orderId) Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found")); .orElseThrow(() -> new RuntimeException("Order not found"));
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// Fallback to on-the-fly generation if the stored file is missing or unreadable.
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId); List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); Map<String, Object> vars = new HashMap<>();
String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; vars.put("sellerDisplayName", "3D Fab Switzerland");
String truncatedUuid = order.getId().toString().substring(0, 8); 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() return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") .header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.body(pdf); .body(pdf);
} }
private Path buildConfirmationPdfRelativePath(Order order) {
return Path.of(
"orders",
order.getId().toString(),
"documents",
"confirmation-" + getDisplayOrderNumber(order) + ".pdf"
);
}
@GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(order, 360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order));
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
return ResponseEntity.ok(data);
}
@GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
}
@GetMapping("/{orderId}/twint/qr")
public ResponseEntity<byte[]> getTwintQr(
@PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size
) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(order, normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
}
private String getExtension(String filename) { private String getExtension(String filename) {
if (filename == null) return "stl"; if (filename == null) return "stl";
String cleaned = StringUtils.cleanPath(filename); int i = filename.lastIndexOf('.');
if (cleaned.contains("..")) { if (i > 0) {
return "stl"; return filename.substring(i + 1);
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
} }
return "stl"; return "stl";
} }
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 OrderDto convertToDto(Order order, List<OrderItem> items) { private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto(); OrderDto dto = new OrderDto();
dto.setId(order.getId()); dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus()); dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone()); dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType()); dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency()); dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf()); dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf()); dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf()); dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf()); 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.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt()); dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
@@ -354,12 +255,4 @@ public class OrderController {
return dto; 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";
}
} }

View File

@@ -1,11 +1,15 @@
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.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.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;
@@ -15,24 +19,29 @@ import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.logging.Logger;
@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 com.printcalculator.service.ClamAVService clamAVService; private final ProfileManager profileManager;
// 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, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
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;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -46,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 ...
@@ -74,6 +83,9 @@ public class QuoteController {
} }
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) {
@@ -83,7 +95,7 @@ public class QuoteController {
// 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")
@@ -91,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,92 +1,78 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine; 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.model.ModelDimensions; 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.repository.FilamentMaterialTypeRepository; import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PrinterMachineRepository; 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.OrcaProfileResolver;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
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.util.StringUtils;
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 java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
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.nio.file.StandardCopyOption;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap; 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 java.util.Optional;
import java.util.Locale;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.web.server.ResponseStatusException; import java.util.logging.Logger;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
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 SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final ProfileManager profileManager;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final OrcaProfileResolver orcaProfileResolver;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final com.printcalculator.service.StorageService storageService;
private final QuoteSessionTotalsService quoteSessionTotalsService;
// Defaults
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, SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
ProfileManager profileManager,
PrinterMachineRepository machineRepo, PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService, com.printcalculator.service.StorageService storageService) {
QuoteSessionTotalsService quoteSessionTotalsService) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.profileManager = profileManager;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService; this.storageService = storageService;
this.quoteSessionTotalsService = quoteSessionTotalsService;
} }
// 1. Start a new empty session // 1. Start a new empty session
@@ -98,13 +84,13 @@ public class QuoteSessionController {
session.setPricingVersion("v1"); session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated? // Default material/settings will be set when items are added or updated?
// For now set safe defaults // For now set safe defaults
session.setMaterialCode("PLA"); 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);
@@ -127,151 +113,156 @@ public class QuoteSessionController {
// Helper to add item // Helper to add item
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) throw new IllegalArgumentException("File is empty"); if (file.isEmpty()) throw new IOException("File is empty");
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
}
// Scan for virus
clamAVService.scan(file.getInputStream());
// 1. Define Persistent Storage Path // 1. Define Persistent Storage Path
// Structure: storage_quotes/{sessionId}/{uuid}.{ext} // Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
}
Files.createDirectories(sessionStorageDir);
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String ext = getSafeExtension(originalFilename, "stl"); String ext = originalFilename != null && originalFilename.contains(".")
String storedFilename = UUID.randomUUID() + "." + ext; ? originalFilename.substring(originalFilename.lastIndexOf("."))
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize(); : ".stl";
if (!persistentPath.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path"); String storedFilename = UUID.randomUUID() + ext;
} Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
// Save file // Save file
try (InputStream inputStream = file.getInputStream()) { storageService.store(file, relativePath);
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
Path convertedPersistentPath = null; // Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
com.printcalculator.model.StlShiftResult shift = null;
try { try {
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus()); // Apply Basic/Advanced Logic
// In CAD sessions, print settings are locked server-side.
if (cadSession) {
enforceCadPrintSettings(session, settings);
} else {
applyPrintSettings(settings); applyPrintSettings(settings);
// REAL SLICING
// 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()));
} }
BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); // 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile();
// Pick machine (selected machine if provided, otherwise first active) if (machineProfile == null || machineProfile.isBlank()) {
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
// Resolve selected filament variant
FilamentVariant selectedVariant = resolveFilamentVariant(settings);
if (cadSession
&& session.getMaterialCode() != null
&& selectedVariant.getFilamentMaterialType() != null
&& selectedVariant.getFilamentMaterialType().getMaterialCode() != null) {
String lockedMaterial = normalizeRequestedMaterialCode(session.getMaterialCode());
String selectedMaterial = normalizeRequestedMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
if (!lockedMaterial.equals(selectedMaterial)) {
throw new ResponseStatusException(BAD_REQUEST, "Selected filament does not match locked CAD material");
} }
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 global settings from the most recent item added // Update Session Settings for Persistence
if (!cadSession) { if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
session.setNozzleDiameterMm(nozzleDiameter); if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
session.setInfillPattern(settings.getInfillPattern()); if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); if (settings.getNotes() != null) session.setNotes(settings.getNotes());
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
// Save session updates
sessionRepo.save(session); sessionRepo.save(session);
}
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); String processProfile = "0.20mm Standard @BBL A1";
String machineProfile = profiles.machineProfileName(); // Mapping quality to process
String filamentProfile = profiles.filamentProfileName(); // "standard" -> "0.20mm Standard @BBL A1"
// "draft" -> "0.28mm Extra Draft @BBL A1"
String processProfile = "standard"; // "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() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "draft"; if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine"; else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
} }
// Build overrides map from settings
// Build overrides map from settings // Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>(); Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); 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.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) {
Path slicerInputPath = persistentPath; processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
if ("3mf".equals(ext)) { // If enabled, use a more permissive threshold (45 deg) by default
String convertedFilename = UUID.randomUUID() + "-converted.stl"; // to avoid expensive supports on things that don't strictly need them
convertedPersistentPath = sessionStorageDir.resolve(convertedFilename).normalize(); if (settings.getSupportsEnabled()) {
if (!convertedPersistentPath.startsWith(sessionStorageDir)) { processOverrides.put("support_threshold_angle", "45");
throw new IOException("Invalid converted STL storage path");
} }
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
slicerInputPath = convertedPersistentPath;
} }
// 3. Slice (Use persistent path) 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( PrintStats stats = slicerService.slice(
slicerInputPath.toFile(), sliceInput,
machineProfile, machineProfile,
filamentProfile, filamentProfile,
processProfile, processProfile,
null, // machine overrides machineOverrides, // machine overrides
processOverrides processOverrides
); );
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile()); // 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 4. Calculate Quote // 6. Create Line Item
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem(); QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session); item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename()); item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root) item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setQuantity(1); item.setQuantity(1);
item.setColorCode(selectedVariant.getColorName()); item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setFilamentVariant(selectedVariant);
item.setStatus("READY"); // or CALCULATED item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds()); item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown // Store breakdown
Map<String, Object> breakdown = new HashMap<>(); Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
breakdown.put("setup_fee", 0); // Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
if (convertedPersistentPath != null) { breakdown.put("setup_fee", result.getSetupCost());
breakdown.put("convertedStoredPath", QUOTE_STORAGE_ROOT.relativize(convertedPersistentPath).toString());
}
item.setPricingBreakdown(breakdown); item.setPricingBreakdown(breakdown);
// Dimensions for shipping/package checks are computed server-side from the uploaded model. // Dimensions from STL
item.setBoundingBoxXMm(modelDimensions item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
.map(dim -> BigDecimal.valueOf(dim.xMm())) item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO)); item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
item.setBoundingBoxYMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.yMm()))
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
item.setBoundingBoxZMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.zMm()))
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
item.setCreatedAt(OffsetDateTime.now()); item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
@@ -280,13 +271,45 @@ public class QuoteSessionController {
} catch (Exception e) { } catch (Exception e) {
// Cleanup if failed // Cleanup if failed
Files.deleteIfExists(persistentPath); try {
if (convertedPersistentPath != null) { storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
Files.deleteIfExists(convertedPersistentPath); } catch (Exception ignored) {}
}
throw e; 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) { private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
@@ -298,117 +321,33 @@ public class QuoteSessionController {
settings.setLayerHeight(0.28); settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0); settings.setInfillDensity(15.0);
settings.setInfillPattern("grid"); settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
case "high": case "high":
settings.setLayerHeight(0.12); settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0); settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid"); settings.setInfillPattern("gyroid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
case "standard": case "standard":
default: default:
settings.setLayerHeight(0.20); settings.setLayerHeight(0.20);
settings.setInfillDensity(15.0); settings.setInfillDensity(20.0);
settings.setInfillPattern("grid"); settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
} }
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} else { } else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing // ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} }
} }
private void enforceCadPrintSettings(QuoteSession session, com.printcalculator.dto.PrintSettingsDto settings) {
settings.setComplexityMode("ADVANCED");
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
}
private PrinterMachine resolvePrinterMachine(Long printerMachineId) {
if (printerMachineId != null) {
PrinterMachine selected = machineRepo.findById(printerMachineId)
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId));
if (!Boolean.TRUE.equals(selected.getIsActive())) {
throw new RuntimeException("Selected printer machine is not active");
}
return selected;
}
return machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) {
if (settings.getFilamentVariantId() != null) {
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
.orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId()));
if (!Boolean.TRUE.equals(variant.getIsActive())) {
throw new RuntimeException("Selected filament variant is not active");
}
return variant;
}
String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial());
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode)
.orElseGet(() -> materialRepo.findByMaterialCode("PLA")
.orElseThrow(() -> new RuntimeException("Fallback material PLA not configured")));
String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null;
if (requestedColor != null && !requestedColor.isBlank()) {
Optional<FilamentVariant> byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor);
if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) {
return byColor.get();
}
}
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
}
private String normalizeRequestedMaterialCode(String value) {
if (value == null || value.isBlank()) {
return "PLA";
}
return value.trim()
.toUpperCase(Locale.ROOT)
.replace('_', ' ')
.replace('-', ' ')
.replaceAll("\\s+", " ");
}
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;
}
// 3. Update Line Item // 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}") @PatchMapping("/line-items/{lineItemId}")
@Transactional @Transactional
@@ -419,19 +358,11 @@ public class QuoteSessionController {
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
QuoteSession session = item.getQuoteSession();
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
}
if (updates.containsKey("quantity")) { if (updates.containsKey("quantity")) {
item.setQuantity(parsePositiveQuantity(updates.get("quantity"))); item.setQuantity((Integer) updates.get("quantity"));
} }
if (updates.containsKey("color_code")) { if (updates.containsKey("color_code")) {
Object colorValue = updates.get("color_code"); item.setColorCode((String) updates.get("color_code"));
if (colorValue != null) {
item.setColorCode(String.valueOf(colorValue));
}
} }
// Recalculate price if needed? // Recalculate price if needed?
@@ -467,44 +398,22 @@ public class QuoteSessionController {
.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);
// Map items to DTO to embed distributed machine cost // Calculate Totals
List<Map<String, Object>> itemsDto = new ArrayList<>(); BigDecimal itemsTotal = BigDecimal.ZERO;
for (QuoteLineItem item : items) { for (QuoteLineItem item : items) {
Map<String, Object> dto = new HashMap<>(); BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
dto.put("id", item.getId()); itemsTotal = itemsTotal.add(lineTotal);
dto.put("originalFilename", item.getOriginalFilename()); }
dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("status", item.getStatus());
dto.put("convertedStoredPath", extractConvertedStoredPath(item));
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO; BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1; BigDecimal grandTotal = itemsTotal.add(setupFee);
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.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);
unitPrice = unitPrice.add(unitMachineCost);
}
dto.put("unitPriceChf", unitPrice);
itemsDto.add(dto);
}
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("session", session); response.put("session", session);
response.put("items", itemsDto); response.put("items", items);
response.put("printItemsTotalChf", totals.printItemsTotalChf()); response.put("itemsTotalChf", itemsTotal);
response.put("cadTotalChf", totals.cadTotalChf()); response.put("grandTotalChf", grandTotal);
response.put("itemsTotalChf", totals.itemsTotalChf());
response.put("shippingCostChf", totals.shippingCostChf());
response.put("globalMachineCostChf", totals.globalMachineCostChf());
response.put("grandTotalChf", totals.grandTotalChf());
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@@ -513,8 +422,7 @@ public class QuoteSessionController {
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent( public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
@PathVariable UUID sessionId, @PathVariable UUID sessionId,
@PathVariable UUID lineItemId, @PathVariable UUID lineItemId
@RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview
) 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"));
@@ -523,83 +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 = extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
}
}
if (targetStoredPath == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
Path path = resolveStoredQuotePath(targetStoredPath, sessionId); Path path = Paths.get(item.getStoredPath());
if (path == null || !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();
} }
org.springframework.core.io.Resource resource = new UrlResource(path.toUri()); org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri());
String downloadName = item.getOriginalFilename();
if (preview) {
downloadName = path.getFileName().toString();
}
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);
} }
private String getSafeExtension(String filename, String fallback) {
if (filename == null) {
return fallback;
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return fallback;
}
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) {
return fallback;
}
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}
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 extractConvertedStoredPath(QuoteLineItem item) {
Map<String, Object> breakdown = item.getPricingBreakdown();
if (breakdown == null) {
return null;
}
Object converted = breakdown.get("convertedStoredPath");
if (converted == null) {
return null;
}
String path = String.valueOf(converted).trim();
return path.isEmpty() ? null : path;
}
} }

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,355 +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.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.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
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;
@RestController
@RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true)
public class AdminFilamentController {
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 AdminFilamentController(
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo
) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
}
@GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
List<AdminFilamentMaterialTypeDto> response = materialRepo.findAll().stream()
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
List<AdminFilamentVariantDto> response = variantRepo.findAll().stream()
.sorted(Comparator
.comparing((FilamentVariant v) -> {
FilamentMaterialType type = v.getFilamentMaterialType();
return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : "";
}, String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER))
.map(this::toVariantDto)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping("/materials")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return ResponseEntity.ok(toMaterialDto(saved));
}
@PutMapping("/materials/{materialTypeId}")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> updateMaterial(
@PathVariable Long materialTypeId,
@RequestBody 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 ResponseEntity.ok(toMaterialDto(saved));
}
@PostMapping("/variants")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody 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 ResponseEntity.ok(toVariantDto(saved));
}
@PutMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> updateVariant(
@PathVariable Long variantId,
@RequestBody 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 ResponseEntity.ok(toVariantDto(saved));
}
@DeleteMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable 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);
return ResponseEntity.noContent().build();
}
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();
}
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,509 +0,0 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto;
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.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 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;
@RestController
@RequestMapping("/api/admin")
@Transactional(readOnly = true)
public class AdminOperationsController {
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.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 AdminOperationsController(
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;
}
@GetMapping("/filament-stock")
public ResponseEntity<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));
}
List<AdminFilamentStockDto> response = 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();
return ResponseEntity.ok(response);
}
@GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
List<AdminContactRequestDto> response = customQuoteRequestRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toContactRequestDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable 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 ResponseEntity.ok(toContactRequestDetailDto(request, attachments));
}
@PatchMapping("/contact-requests/{requestId}/status")
@Transactional
public ResponseEntity<AdminContactRequestDetailDto> updateContactRequestStatus(
@PathVariable UUID requestId,
@RequestBody 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 ResponseEntity.ok(toContactRequestDetailDto(saved, attachments));
}
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
public ResponseEntity<Resource> downloadContactRequestAttachment(
@PathVariable UUID requestId,
@PathVariable 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");
}
}
@GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toQuoteSessionDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/cad-invoices")
public ResponseEntity<List<AdminCadInvoiceDto>> getCadInvoices() {
List<AdminCadInvoiceDto> response = quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED"))
.stream()
.filter(this::isCadSessionRecord)
.map(this::toCadInvoiceDto)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping("/cad-invoices")
@Transactional
public ResponseEntity<AdminCadInvoiceDto> createOrUpdateCadInvoice(
@RequestBody 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 ResponseEntity.ok(toCadInvoiceDto(saved));
}
@DeleteMapping("/sessions/{sessionId}")
@Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable 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);
return ResponseEntity.noContent().build();
}
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,332 +0,0 @@
package com.printcalculator.controller.admin;
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.QuoteSession;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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 org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
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;
@RestController
@RequestMapping("/api/admin/orders")
@Transactional(readOnly = true)
public class AdminOrderController {
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 PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public AdminOrderController(
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> confirmPayment(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/status")
@Transactional
public ResponseEntity<OrderDto> updateOrderStatus(
@PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload
) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.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)
);
}
order.setStatus(normalizedStatus);
orderRepo.save(order);
return ResponseEntity.ok(toOrderDto(order));
}
@GetMapping("/{orderId}/items/{orderItemId}/file")
public ResponseEntity<Resource> downloadOrderItemFile(
@PathVariable UUID orderId,
@PathVariable 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 = storageService.loadAsResource(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 (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
@GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
@GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable 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(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.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(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;
}).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 Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
}

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

@@ -10,16 +10,7 @@ public record OptionsResponse(
List<NozzleOptionDTO> nozzleDiameters List<NozzleOptionDTO> nozzleDiameters
) { ) {
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) {}

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,24 +8,17 @@ public class PrintSettingsDto {
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 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;
} }

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;
} }

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

@@ -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,10 +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;
@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;
@@ -61,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;
@@ -80,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;
} }
@@ -162,14 +139,6 @@ public class OrderItem {
this.materialCode = materialCode; this.materialCode = materialCode;
} }
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }
@@ -202,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;
} }

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,11 +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 = "bounding_box_x_mm", precision = 10, scale = 3) @Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm; private BigDecimal boundingBoxXMm;
@@ -129,14 +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 BigDecimal getBoundingBoxXMm() { public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm; return boundingBoxXMm;
} }

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,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,496 +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.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.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);
}
}
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 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 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());
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); Map<String, String> response = new HashMap<>();
// Check for specific virus case
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
response.put("error", "Security Violation");
// Safe message for client
response.put("message", "File rejected by security policy.");
response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
} }
@ExceptionHandler(VirusDetectedException.class) // Generic fallback for other storage errors to avoid leaking internal paths/details
public ResponseEntity<Object> handleVirusDetectedException( response.put("error", "Storage Operation Failed");
VirusDetectedException ex, WebRequest request) { response.put("message", "Unable to process the file upload.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).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) @ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Object> handleValidationException( public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
MethodArgumentNotValidException ex, WebRequest request) { Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
List<String> details = new ArrayList<>(); response.put("message", "The uploaded file exceeds the maximum allowed size.");
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
details.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
} }
Map<String, Object> body = new LinkedHashMap<>(); @ExceptionHandler(ModelTooLargeException.class)
body.put("timestamp", LocalDateTime.now()); public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
body.put("message", "Dati non validi."); Map<String, String> response = new HashMap<>();
body.put("error", "Validation Error"); response.put("error", "Model too large");
body.put("details", details); response.put("code", "MODEL_TOO_LARGE");
response.put("message", String.format(
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); "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);
} }
@ExceptionHandler(IllegalArgumentException.class) private String formatMm(double value) {
public ResponseEntity<Object> handleIllegalArgumentException( return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
IllegalArgumentException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Bad Request");
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
private String extractPath(WebRequest request) {
String raw = request.getDescription(false);
if (raw == null) {
return "";
}
return raw.startsWith("uri=") ? raw.substring(4) : raw;
} }
} }

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

@@ -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; 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) {
logger.info("Initializing ClamAV client at {}:{}", host, port);
client = new ClamavClient(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

@@ -21,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;
} }
@@ -41,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());
@@ -62,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

@@ -8,18 +8,9 @@ import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context; import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.stream.Collectors; import java.io.IOException;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
@Service @Service
public class InvoicePdfRenderingService { public class InvoicePdfRenderingService {
@@ -54,107 +45,4 @@ public class InvoicePdfRenderingService {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException); throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
} }
} }
public byte[] generateDocumentPdf(Order order, List<OrderItem> items, boolean isConfirmation, QrBillService qrBillService, Payment payment) {
Map<String, Object> vars = new HashMap<>();
vars.put("isConfirmation", isConfirmation);
vars.put("sellerDisplayName", "3D Fab Küng Caletti");
vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank()
? order.getOrderNumber()
: order.getId().toString();
vars.put("invoiceNumber", "INV-" + displayOrderNumber.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());
// Setup Shipping Info
if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) {
String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank()
? order.getShippingCompanyName()
: order.getShippingFirstName() + " " + order.getShippingLastName();
vars.put("shippingDisplayName", shippingName);
vars.put("shippingAddressLine1", order.getShippingAddressLine1());
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
}
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());
if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) {
BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO;
BigDecimal cadHourlyRate = order.getCadHourlyRateChf() != null ? order.getCadHourlyRateChf() : BigDecimal.ZERO;
Map<String, Object> cadLine = new HashMap<>();
cadLine.put("description", "Servizio CAD (" + formatCadHours(cadHours) + "h)");
cadLine.put("quantity", 1);
cadLine.put("unitPriceFormatted", String.format("CHF %.2f", cadHourlyRate));
cadLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getCadTotalChf()));
invoiceLineItems.add(cadLine);
}
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", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto.");
String paymentMethodText = "QR / Bonifico oppure TWINT";
if (payment != null && payment.getMethod() != null) {
paymentMethodText = switch (payment.getMethod().toUpperCase()) {
case "TWINT" -> "TWINT";
case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario";
case "QR_BILL", "QR" -> "QR Bill";
case "CASH" -> "Contanti";
default -> payment.getMethod();
};
}
vars.put("paymentMethodText", paymentMethodText);
String qrBillSvg = null;
if (isConfirmation) {
qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
}
return generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
}
private String formatCadHours(BigDecimal hours) {
return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
} }

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,19 +8,20 @@ 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 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.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 {
@@ -32,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,
@@ -43,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;
@@ -55,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"));
@@ -85,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);
@@ -104,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());
@@ -144,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(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); 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);
@@ -167,34 +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());
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.setMaterialCode(session.getMaterialCode());
}
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());
@@ -227,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) {
order.setShippingCostChf(BigDecimal.valueOf(9.00));
}
BigDecimal total = order.getSubtotalChf() BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
.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());
@@ -242,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();
@@ -290,36 +297,4 @@ public class OrderService {
} }
return "stl"; return "stl";
} }
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

@@ -1,101 +0,0 @@
package com.printcalculator.service;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
@Service
public class PaymentService {
private final PaymentRepository paymentRepo;
private final OrderRepository orderRepo;
private final ApplicationEventPublisher eventPublisher;
public PaymentService(PaymentRepository paymentRepo,
OrderRepository orderRepo,
ApplicationEventPublisher eventPublisher) {
this.paymentRepo = paymentRepo;
this.orderRepo = orderRepo;
this.eventPublisher = eventPublisher;
}
@Transactional
public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) {
Optional<Payment> existing = paymentRepo.findByOrder_Id(order.getId());
if (existing.isPresent()) {
return existing.get();
}
Payment payment = new Payment();
payment.setOrder(order);
// Default to "OTHER" always, as payment method should only be set by the admin explicitly
payment.setMethod("OTHER");
payment.setStatus("PENDING");
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
payment.setInitiatedAt(OffsetDateTime.now());
return paymentRepo.save(payment);
}
@Transactional
public Payment reportPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
if (!"PENDING".equals(payment.getStatus())) {
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
}
payment.setStatus("REPORTED");
payment.setReportedAt(OffsetDateTime.now());
// We intentionally do not update the payment method here based on user input,
// because the user cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment);
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
return payment;
}
@Transactional
public Payment confirmPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER"));
payment.setStatus("COMPLETED");
if (method != null && !method.isBlank()) {
payment.setMethod(method.toUpperCase());
}
payment.setReceivedAt(OffsetDateTime.now());
payment = paymentRepo.save(payment);
order.setStatus("IN_PRODUCTION");
order.setPaidAt(OffsetDateTime.now());
orderRepo.save(order);
eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment));
return payment;
}
}

View File

@@ -10,23 +10,19 @@ import java.io.IOException;
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.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;
@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 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;
@@ -36,8 +32,6 @@ public class ProfileManager {
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() {
@@ -46,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");
@@ -62,82 +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);
} }
private Path findProfileFile(String name, String type) { public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
if (!Files.isDirectory(resolvedProfilesRoot)) { String resolvedName = profileAliases.getOrDefault(machineName, machineName);
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); if (nozzleDiameter == null) return resolvedName;
return null;
String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
String candidate = base + " " + formatted + " nozzle";
Path exists = findProfileFile(candidate, "machine");
return exists != null ? candidate : resolvedName;
} }
private Path findProfileFile(String name, String type) {
// 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()
.toList();
if (candidates.isEmpty()) {
return null;
}
if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) {
Optional<Path> typed = candidates.stream()
.filter(p -> pathContainsSegment(p, type))
.findFirst(); .findFirst();
if (typed.isPresent()) { return found.orElse(null);
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());
@@ -145,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)
@@ -189,30 +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";
}
} }

View File

@@ -22,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",
@@ -49,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);
// --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg
// 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());
} }
public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) { BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) {
throw new RuntimeException("No active pricing policy found");
}
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
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,124 +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.PricingPolicyRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Arrays;
import java.util.List;
@Service
public class QuoteSessionTotalsService {
private final PricingPolicyRepository pricingRepo;
private final QuoteCalculator quoteCalculator;
public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo, QuoteCalculator quoteCalculator) {
this.pricingRepo = pricingRepo;
this.quoteCalculator = quoteCalculator;
}
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 setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal shippingCost = calculateShippingCost(items);
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost);
return new QuoteSessionTotals(
printItemsTotal,
globalMachineCost,
cadTotal,
itemsTotal,
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 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 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,93 +0,0 @@
package com.printcalculator.service;
import io.nayuki.qrcodegen.QrCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
@Service
public class TwintPaymentService {
private final String twintPaymentUrl;
public TwintPaymentService(
@Value("${payment.twint.url:}")
String twintPaymentUrl
) {
this.twintPaymentUrl = twintPaymentUrl;
}
public String getTwintPaymentUrl(com.printcalculator.entity.Order order) {
if (twintPaymentUrl == null || twintPaymentUrl.isBlank()) {
throw new IllegalStateException("TWINT_PAYMENT_URL is not configured");
}
StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl);
if (order != null) {
if (order.getTotalChf() != null) {
urlBuilder.append("&amount=").append(order.getTotalChf().toPlainString());
}
String orderNumber = order.getOrderNumber();
if (orderNumber == null && order.getId() != null) {
orderNumber = order.getId().toString();
}
if (orderNumber != null) {
try {
urlBuilder.append("&trxInfo=").append(order.getId());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
return urlBuilder.toString();
}
public byte[] generateQrPng(com.printcalculator.entity.Order order, int sizePx) {
try {
String url = getTwintPaymentUrl(order);
// Use High Error Correction for financial QR codes
QrCode qrCode = QrCode.encodeText(url, QrCode.Ecc.HIGH);
// Standard QR quiet zone is 4 modules
int borderModules = 4;
int fullModules = qrCode.size + borderModules * 2;
int scale = Math.max(1, sizePx / fullModules);
int imageSize = fullModules * scale;
BufferedImage image = new BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, imageSize, imageSize);
graphics.setColor(Color.BLACK);
for (int y = 0; y < qrCode.size; y++) {
for (int x = 0; x < qrCode.size; x++) {
if (qrCode.getModule(x, y)) {
int px = (x + borderModules) * scale;
int py = (y + borderModules) * scale;
graphics.fillRect(px, py, scale, scale);
}
}
}
} finally {
graphics.dispose();
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("Unable to generate TWINT QR image.", ex);
}
}
}

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,8 +0,0 @@
app.mail.enabled=false
app.mail.admin.enabled=false
app.mail.contact-request.admin.enabled=false
# Admin back-office local test credentials
admin.password=local-admin-password
admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
admin.session.ttl-minutes=480

View File

@@ -7,13 +7,11 @@ spring.datasource.username=${DB_USERNAME:printcalc}
spring.datasource.password=${DB_PASSWORD:printcalc_secret} spring.datasource.password=${DB_PASSWORD:printcalc_secret}
spring.jpa.hibernate.ddl-auto=update spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false
# Slicer Configuration # Slicer Configuration
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path) # Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer} slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
assimp.path=${ASSIMP_PATH:assimp}
profiles.root=${PROFILES_DIR:profiles} profiles.root=${PROFILES_DIR:profiles}
@@ -26,32 +24,3 @@ clamav.host=${CLAMAV_HOST:clamav}
clamav.port=${CLAMAV_PORT:3310} clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:}
# Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch}
spring.mail.password=${MAIL_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
# Application Mail Settings
app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true}
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
# Admin back-office authentication
admin.password=${ADMIN_PASSWORD}
admin.session.secret=${ADMIN_SESSION_SECRET}
admin.session.ttl-minutes=${ADMIN_SESSION_TTL_MINUTES:480}
admin.auth.trust-proxy-headers=${ADMIN_AUTH_TRUST_PROXY_HEADERS:false}
# Expose only liveness endpoint by default.
management.endpoints.web.exposure.include=health

View File

@@ -1,121 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Nuova richiesta di contatto</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 640px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-top: 0;
color: #222222;
}
p {
color: #444444;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eeeeee;
padding: 10px 6px;
color: #333333;
word-break: break-word;
}
th {
width: 35%;
color: #222222;
background: #fafafa;
}
.footer {
margin-top: 24px;
font-size: 12px;
color: #888888;
border-top: 1px solid #eeeeee;
padding-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Nuova richiesta di contatto</h1>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table>
<tr>
<th>ID richiesta</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th>Data</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th>Tipo richiesta</th>
<td th:text="${requestType}">PRINT_SERVICE</td>
</tr>
<tr>
<th>Tipo cliente</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th>Nome</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th>Azienda</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th>Contatto</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th>Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th>Telefono</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th>Messaggio</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th>Allegati</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,134 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Contact request received</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 640px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-top: 0;
color: #222222;
}
h2 {
margin-top: 18px;
color: #222222;
font-size: 18px;
}
p {
color: #444444;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eeeeee;
padding: 10px 6px;
color: #333333;
word-break: break-word;
}
th {
width: 35%;
color: #222222;
background: #fafafa;
}
.footer {
margin-top: 24px;
font-size: 12px;
color: #888888;
border-top: 1px solid #eeeeee;
padding-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1 th:text="${headlineText}">We received your contact request</h1>
<p th:text="${greetingText}">Hi customer,</p>
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
<p>
<strong th:text="${requestIdHintText}">Please keep this request ID for future order references:</strong>
<span th:text="${requestId}">00000000-0000-0000-0000-000000000000</span>
</p>
<h2 th:text="${detailsTitleText}">Request details</h2>
<table>
<tr>
<th th:text="${labelRequestId}">Request ID</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th th:text="${labelDate}">Date</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th th:text="${labelRequestType}">Request type</th>
<td th:text="${requestType}">custom</td>
</tr>
<tr>
<th th:text="${labelCustomerType}">Customer type</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th th:text="${labelName}">Name</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th th:text="${labelCompany}">Company</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th th:text="${labelContactPerson}">Contact person</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th th:text="${labelEmail}">Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th th:text="${labelPhone}">Phone</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th th:text="${labelMessage}">Message</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th th:text="${labelAttachments}">Attachments</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<p th:text="${supportText}">If you need help, reply to this email.</p>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> <span th:text="${footerText}">Automated request-receipt confirmation from 3D-Fab.</span></p>
</div>
</div>
</body>
</html>

View File

@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Order Confirmation</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.order-details {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.order-details th {
text-align: left;
padding-right: 20px;
color: #333333;
vertical-align: top;
}
.order-details td {
word-break: break-word;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">We received your order and started processing it.</p>
<div class="order-details">
<p style="margin-top:0; font-weight: bold;" th:text="${detailsTitleText}">Order details</p>
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelDate}">Date</th>
<td th:text="${orderDate}">Jan 1, 2026, 10:00:00 AM</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p>
<span th:text="${orderDetailsCtaText}">View order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${attachmentHintText}">The order confirmation PDF is attached.</p>
<p th:text="${supportText}">If you have questions, reply to this email.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,111 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Payment Confirmed</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.status-box {
background-color: #e8f8ee;
border: 1px solid #9fd4af;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box {
background-color: #f9f9f9;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box th {
text-align: left;
padding-right: 18px;
vertical-align: top;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">Your payment has been confirmed and your order is now in production.</p>
<div class="status-box">
<strong th:text="${statusText}">Current status: In production.</strong>
</div>
<div class="order-box">
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p th:text="${attachmentHintText}">The paid invoice PDF is attached to this email.</p>
<p>
<span th:text="${orderDetailsCtaText}">View order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${supportText}">We will notify you when shipment is ready.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Payment Reported</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.status-box {
background-color: #fff9e8;
border: 1px solid #f4d68a;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box {
background-color: #f9f9f9;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box th {
text-align: left;
padding-right: 18px;
vertical-align: top;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">We received your payment report and we are now verifying it.</p>
<div class="status-box">
<strong th:text="${statusText}">Current status: Payment under verification.</strong>
</div>
<div class="order-box">
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p>
<span th:text="${orderDetailsCtaText}">Check order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${supportText}">You will receive another email once payment is confirmed.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -3,410 +3,81 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<style> <style>
@page invoice { @page { size: A4; margin: 18mm 15mm; }
size: A4; body { font-family: sans-serif; font-size: 10.5pt; }
margin: 12mm 12mm 12mm 12mm; .header { display: flex; justify-content: space-between; }
} .addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
@page qrpage { th, td { padding: 6px; border-bottom: 1px solid #ccc; }
size: A4; th { text-align: left; }
margin: 0; .totals { margin-top: 6mm; width: 40%; margin-left: auto; }
} .totals td { border: none; }
.page-break { page-break-before: always; }
*, *:before, *:after {
box-sizing: border-box;
}
body {
page: invoice;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 8.5pt;
margin: 0;
padding: 0;
background: #fff;
color: #000;
line-height: 1.35;
}
.invoice-page {
page: invoice;
width: 100%;
page-break-after: always;
}
/* Top Header Layout */
.header-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 25mm;
}
.header-layout td {
vertical-align: top;
padding: 0;
}
.logo-block {
width: 33%;
font-size: 24pt;
font-weight: bold;
letter-spacing: -0.5px;
}
.logo-3d {
color: #111827; /* Dark black/blue */
}
.logo-fab {
color: #eab308; /* Yellow/Gold */
}
.seller-block {
width: 33%;
font-size: 9pt;
line-height: 1.4;
}
.website-block {
width: 33%;
text-align: right;
font-size: 9pt;
}
/* Document Title */
.doc-title {
font-size: 20pt;
font-weight: normal;
margin: 0 0 10mm 0;
letter-spacing: -0.5px;
}
/* Meta and Customer Details Layout */
.details-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 15mm;
}
.details-layout td {
vertical-align: top;
padding: 0;
}
.meta-container {
width: 50%;
}
.customer-container {
width: 50%;
font-size: 10pt;
line-height: 1.5;
}
.meta-table {
border-collapse: collapse;
font-size: 8.5pt;
}
.meta-table td {
padding: 1.5mm 0;
vertical-align: top;
}
.meta-label {
width: 45mm;
padding-right: 2mm;
}
.meta-value {
/* allow wrapping just in case */
}
/* Line Items Table */
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 5mm;
font-size: 8.5pt;
}
.line-items th,
.line-items td {
padding: 1.5mm 0;
vertical-align: top;
}
.line-items th {
text-align: left;
font-weight: normal;
border-bottom: 1pt solid #000;
}
.line-items tbody td {
border-bottom: 0.5pt solid #e0e0e0;
}
.line-items tbody tr:last-child td {
border-bottom: 1pt solid #000;
}
.line-items th.center,
.line-items td.center {
text-align: center;
}
.line-items th.right,
.line-items td.right {
text-align: right;
}
.col-desc { width: 45%; }
.col-qty { width: 10%; }
.col-price { width: 22%; }
.col-total { width: 23%; }
.item-desc {
padding-right: 4mm;
}
/* Totals Block */
.totals-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 0;
font-size: 8.5pt;
}
.totals-table td {
padding: 1.5mm 0;
border-bottom: 0.5pt solid #000;
}
.totals-label {
text-align: left;
}
.totals-value {
text-align: right;
width: 30%;
}
.totals-table tr.no-border td {
border-bottom: none;
}
.summary-notes {
margin-top: 4mm;
padding-bottom: 4mm;
border-bottom: 1pt solid #000;
}
/* Footer Notes Layout */
.footer-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 15mm;
font-size: 8.5pt;
}
.footer-layout td {
vertical-align: top;
padding: 0 0 3mm 0;
}
.footer-label {
width: 25%;
font-weight: normal;
}
.footer-text {
width: 75%;
line-height: 1.4;
}
/* QR Page */
.qr-only-page {
page: qrpage;
width: 100%;
height: 297mm;
background: #fff;
page-break-inside: avoid;
}
.qr-only-layout {
width: 100%;
height: 297mm;
border-collapse: collapse;
table-layout: fixed;
}
.qr-only-layout td {
vertical-align: bottom;
/* Keep the QR slip at page bottom, but with a safer print margin. */
padding: 0 0 1mm 0;
}
.qr-bill-bottom {
width: 100%;
height: 105mm;
overflow: hidden;
background: #fff;
}
.qr-bill-bottom svg {
width: 100% !important;
height: 105mm !important;
display: block;
}
</style> </style>
</head> </head>
<body> <body>
<div class="invoice-page">
<!-- Header --> <div class="header">
<table class="header-layout"> <div>
<tr> <div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
<td class="logo-block"> <div th:text="${sellerAddressLine1}">Via Esempio 12</div>
<span class="logo-3d">3D</span> <span class="logo-fab">fab</span> <div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div style="font-size: 14pt; font-weight: normal; margin-top: 4px; color: #111827;">Küng Caletti</div> <div th:text="${sellerEmail}">email@example.com</div>
</td>
<td class="seller-block">
<div th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Via G. pioda 29a - 6710 Biasca, Svizzera</div>
<div th:text="${sellerAddressLine2}">Lyss-Strasse 71 - 2560 Nidau, Svizzera</div>
</td>
<td class="website-block">
www.3d-fab.ch
</td>
</tr>
</table>
<!-- Document Title -->
<div class="doc-title">
<span th:if="${isConfirmation}">Conferma dell'ordine</span>
<span th:unless="${isConfirmation}">Fattura</span>
<span th:text="${invoiceNumber}">141052743</span>
<span th:unless="${isConfirmation}" style="color: #2e7d32; font-weight: bold; font-size: 18pt; padding-left: 15px;">PAGATO</span>
</div> </div>
<!-- Details block (Meta and Customer) --> <div>
<table class="details-layout"> <div><strong>Fattura</strong></div>
<tr> <div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
<td class="meta-container"> <div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
<table class="meta-table"> <div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
<tr>
<td class="meta-label">Data dell'ordine / fattura</td>
<td class="meta-value" th:text="${invoiceDate}">07.03.2025</td>
</tr>
<tr>
<td class="meta-label">Numero documento</td>
<td class="meta-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr>
<tr>
<td class="meta-label">Data di scadenza</td>
<td class="meta-value" th:text="${dueDate}">07.03.2025</td>
</tr>
<tr>
<td class="meta-label">Metodo di pagamento</td>
<td class="meta-value" th:text="${paymentMethodText}">QR / Bonifico oppure TWINT</td>
</tr>
<tr>
<td class="meta-label">Valuta</td>
<td class="meta-value">CHF</td>
</tr>
</table>
</td>
<td class="customer-container">
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di fatturazione:</div>
<div th:text="${buyerDisplayName}">Joe Küng</div>
<div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
<div th:text="${buyerAddressLine2}">6710 biasca</div>
<div>Svizzera</div>
<br/>
<div th:if="${shippingDisplayName != null}">
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di spedizione:</div>
<div th:text="${shippingDisplayName}">Joe Küng</div>
<div th:text="${shippingAddressLine1}">Via G.Pioda, 29a</div>
<div th:text="${shippingAddressLine2}">6710 biasca</div>
<div>Svizzera</div>
</div> </div>
</td> </div>
</tr>
</table>
<!-- Items Table --> <div class="addresses">
<table class="line-items"> <div>
<div><strong>Fatturare a</strong></div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</div>
</div>
<table>
<thead> <thead>
<tr> <tr>
<th class="col-desc">Descrizione</th> <th>Descrizione</th>
<th class="col-qty center">Quanti</th> <th style="text-align:right;">Qtà</th>
<th class="col-price right">Prezzo unitario</th> <th style="text-align:right;">Prezzo</th>
<th class="col-total right">Prezzo incl.</th> <th style="text-align:right;">Totale</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="lineItem : ${invoiceLineItems}"> <tr th:each="lineItem : ${invoiceLineItems}">
<td class="item-desc" th:text="${lineItem.description}">Apple iPhone 16 Pro</td> <td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td class="center" th:text="${lineItem.quantity}">1</td> <td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
<td class="right" th:text="${lineItem.unitPriceFormatted}">968.55</td> <td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td class="right" th:text="${lineItem.lineTotalFormatted}">1'047.00</td> <td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Totals --> <table class="totals">
<table class="totals-table">
<tr> <tr>
<td class="totals-label">Importo totale</td> <td>Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">1'012.86</td> <td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr> </tr>
<tr> <tr>
<td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td> <td><strong>Totale</strong></td>
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td> <td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
</tr> </tr>
<tr class="no-border" th:if="${isConfirmation}"> </table>
<td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
</tr>
<tr class="no-border" th:unless="${isConfirmation}">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value">CHF 0.00</td>
</tr>
</table>
<!-- Footer Notes -->
<table class="footer-layout">
<tr>
<td class="footer-label">Informazioni</td>
<td class="footer-text" th:text="${paymentTermsText}">
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</td>
</tr>
<tr>
<td class="footer-label">Generale</td>
<td class="footer-text">
Si applicano le nostre condizioni generali di contratto. Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</td>
</tr>
</table>
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div> </div>
<!-- QR Bill Page (only renders if QR data is passed) --> <div style="page-break-before: always;"></div>
<div class="qr-only-page" th:if="${qrBillSvg != null}"> <div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
<table class="qr-only-layout">
<tr>
<td>
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
</td>
</tr>
</table>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,151 @@
package com.printcalculator;
import com.printcalculator.controller.QuoteSessionController;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.StorageService;
import com.printcalculator.service.StlService;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Map;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.*;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(QuoteSessionController.class)
public class ManualSessionPersistenceTest {
@Autowired
private QuoteSessionController controller;
@MockitoBean
private QuoteSessionRepository sessionRepo;
@MockitoBean
private QuoteLineItemRepository lineItemRepo; // Mock this too
@MockitoBean
private SlicerService slicerService;
@MockitoBean
private StorageService storageService;
@MockitoBean
private StlService stlService;
@MockitoBean
private ProfileManager profileManager;
@MockitoBean
private QuoteCalculator quoteCalculator;
@MockitoBean
private PrinterMachineRepository machineRepo;
@MockitoBean
private com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Add this if needed by controller
@Test
public void testSettingsPersistence() throws Exception {
// Prepare
UUID sessionId = UUID.randomUUID();
QuoteSession session = new QuoteSession();
session.setId(sessionId);
session.setMaterialCode("pla_basic"); // Initial state
when(sessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
when(sessionRepo.save(any(QuoteSession.class))).thenAnswer(i -> i.getArguments()[0]);
when(lineItemRepo.save(any(QuoteLineItem.class))).thenAnswer(i -> i.getArguments()[0]);
// 2. Add Item with Custom Settings
PrintSettingsDto settings = new PrintSettingsDto();
settings.setComplexityMode("ADVANCED");
settings.setMaterial("petg_basic");
settings.setLayerHeight(0.12);
settings.setInfillDensity(50.0);
settings.setInfillPattern("gyroid");
settings.setSupportsEnabled(true);
settings.setNozzleDiameter(0.6);
settings.setNotes("Test Notes");
MockMultipartFile file = new MockMultipartFile("file", "test.stl", "application/octet-stream", "dummy content".getBytes());
// Mock dependencies
when(machineRepo.findFirstByIsActiveTrue()).thenReturn(Optional.of(new PrinterMachine(){{
setPrinterDisplayName("TestPrinter");
setSlicerMachineProfile("TestProfile");
setBuildVolumeXMm(256);
setBuildVolumeYMm(256);
setBuildVolumeZMm(256);
}}));
when(slicerService.slice(any(), any(), any(), any(), any(), any())).thenReturn(new PrintStats(100, "1m", 10.0, 100));
when(quoteCalculator.calculate(any(), any(), any())).thenReturn(
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
);
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
when(profileManager.resolveMachineProfileName(any(), any())).thenAnswer(i -> i.getArguments()[0]);
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
@Override
public File getFile() { return new File("dummy"); }
});
controller.addItemToExistingSession(sessionId, settings, file);
// 3. Verify Session Updated via Save Call capture
ArgumentCaptor<QuoteSession> captor = ArgumentCaptor.forClass(QuoteSession.class);
verify(sessionRepo).save(captor.capture());
QuoteSession updatedSession = captor.getValue();
assertEquals("petg_basic", updatedSession.getMaterialCode());
assertEquals(0, BigDecimal.valueOf(0.12).compareTo(updatedSession.getLayerHeightMm()));
assertEquals(50, updatedSession.getInfillPercent());
assertEquals("gyroid", updatedSession.getInfillPattern());
assertTrue(updatedSession.getSupportsEnabled());
assertEquals(0, BigDecimal.valueOf(0.6).compareTo(updatedSession.getNozzleDiameterMm()));
assertEquals("Test Notes", updatedSession.getNotes());
System.out.println("Verification Passed: Settings were persisted to Session.");
}
@org.springframework.boot.test.context.TestConfiguration
static class TestConfig {
@org.springframework.context.annotation.Bean
public org.springframework.transaction.PlatformTransactionManager transactionManager() {
return org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);
}
}
}

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