54 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
2eea773ee2 feat(back-end): files saved in disc
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 16s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-12 21:25:29 +01:00
44f9408b22 feat(back-end & front-end): add session file
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m14s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-12 19:24:58 +01:00
044fba8d5a feat(back-end & front-end): checkout, update form structure, add new DTOs, refactor order logic
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m15s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-12 19:01:48 +01:00
257c60fa5e feat(back-end): refactor session creation
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 56s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-12 17:07:26 +01:00
5a84fb13c0 feat(back-end): cors config update
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 35s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-12 16:10:10 +01:00
89d84ed369 feat(back-end & web): improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-12 16:02:03 +01:00
9c3d5fae12 feat(back-end & ): removed Abstract repository
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 38s
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-12 16:00:03 +01:00
96ae9bb609 feat(back-end): removed Abstract repository
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 15:32:31 +01:00
9cbd856ab6 feat(back-end): new db for custom quote requests
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 42s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 15:26:58 +01:00
87 changed files with 6911 additions and 597 deletions

View File

@@ -21,16 +21,6 @@ jobs:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |
cd backend cd backend

View File

@@ -4,39 +4,42 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e
## Project Overview ## Project Overview
**Nome**: Print Calculator **Nome**: Print Calculator
**Scopo**: Calcolare costi e tempi di stampa 3D da file STL. **Scopo**: Calcolare costi e tempi di stampa 3D da file STL in modo preciso tramite slicing reale.
**Stack**: **Stack**:
- **Backend**: Python (FastAPI), libreria `trimesh` per analisi geometrica. - **Backend**: Java 21 (Spring Boot 3.4), PostgreSQL, Flyway.
- **Frontend**: Angular 19 (TypeScript). - **Frontend**: Angular 19 (TypeScript), Angular Material, Three.js per visualizzazione 3D.
## Architecture ## Architecture
### Backend (`/backend`) ### Backend (`/backend`)
- **`main.py`**: Entrypoint dell'applicazione FastAPI. - **`BackendApplication.java`**: Entrypoint dell'applicazione Spring Boot.
- Definisce l'API `POST /calculate/stl`. - **`controller/`**: Espone le API REST per l'upload e il calcolo dei preventivi.
- Gestisce l'upload del file, invoca lo slicer e restituisce il preventivo. - **`service/SlicerService.java`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello.
- Configura CORS per permettere chiamate dal frontend. - Gestisce i profili di stampa (Macchina, Processo, Filamento) caricati da file JSON.
- **`slicer.py`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello. - Crea configurazioni on-the-fly e invoca OrcaSlicer in modalità headless.
- Gestisce i profili di stampa (Macchina, Processo, Filamento). - **`service/GCodeParser.java`**: Analizza il G-Code generato per estrarre tempo di stampa e peso del materiale dai metadati del file.
- Crea configurazioni on-the-fly per supportare mesh di grandi dimensioni. - **`service/QuoteCalculator.java`**: Calcola il prezzo finale basandosi su politiche di prezzo salvate nel database.
- **`calculator.py`**: Analizza il G-Code generato. - Gestisce costi macchina a scaglioni (tiered pricing).
- `GCodeParser`: Estrae tempo di stampa e materiale usato dai metadati del G-Code. - Calcola costi energetici basati sulla potenza della stampante e costo del kWh.
- `QuoteCalculator`: Applica i costi (orari, energia, materiale) per generare il prezzo finale. - Applica markup percentuali e fee fissi per job.
### Frontend (`/frontend`) ### Frontend (`/frontend`)
- Applicazione Angular standard. - Applicazione Angular 19 con architettura modulare (core, features, shared).
- Usa Angular Material. - **Three.js**: Utilizzato per il rendering dei file STL caricati dall'utente.
- Service per upload STL e visualizzazione preventivo. - **Angular Material**: Per l'interfaccia utente.
- **ngx-translate**: Per il supporto multilingua.
## Key Concepts ## Key Concepts
- **Real Slicing**: Il backend esegue un vero slicing usando OrcaSlicer in modalità headless. Questo garantisce stime di tempo e materiale estremamente precise, identiche a quelle che si otterrebbero preparando il file per la stampa. - **Real Slicing**: Il backend esegue un vero slicing usando OrcaSlicer. Questo garantisce stime di tempo e materiale estremamente precise.
- **G-Code Parsing**: Invece di stimare geometricamente, l'applicazione legge direttamene i commenti generati dallo slicer nel G-Code (es. `estimated printing time`, `filament used`). - **Database-Driven Pricing**: A differenza di versioni precedenti, il calcolo del preventivo è ora guidato da entità DB (`PricingPolicy`, `PrinterMachine`, `FilamentVariant`).
- **G-Code Metadata**: L'applicazione legge direttamene i commenti generati dallo slicer nel G-Code (es. `; estimated printing time`, `; filament used [g]`).
## Development Notes ## Development Notes
- Per eseguire il backend serve `uvicorn`. - **Backend**: Richiede JDK 21. Si avvia con `./gradlew bootRun`.
- Il frontend richiede `npm install` al primo avvio. - **Database**: Richiede PostgreSQL. Le migrazioni sono gestite da Flyway.
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro. - **Frontend**: Richiede Node.js 22. Si avvia con `npm start`.
- **OrcaSlicer**: Deve essere installato sul sistema e il percorso configurato in `application.properties` o tramite variabile d'ambiente `SLICER_PATH`.
## AI Agent Rules ## AI Agent Rules
- **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`. - **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`.
- **Spring Boot Conventions**: Seguire i pattern standard di Spring Boot (Service-Repository-Controller).

View File

@@ -1,70 +1,67 @@
# Print Calculator (OrcaSlicer Edition) # Print Calculator (OrcaSlicer Edition)
Un'applicazione Full Stack (Angular + Python/FastAPI) per calcolare preventivi di stampa 3D precisi utilizzando **OrcaSlicer** in modalità headless. Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di stampa 3D precisi utilizzando **OrcaSlicer** in modalità headless.
## Funzionalità ## Funzionalità
* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, non semplici approssimazioni geometriche. * **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, garantendo la massima precisione.
* **Preventivazione Completa**: Calcola costo materiale, ammortamento macchina, energia e ricarico. * **Preventivazione Database-Driven**: Calcolo basato su politiche di prezzo configurabili nel database (costo materiale, ammortamento macchina a scaglioni, energia e markup).
* **Configurabile**: Prezzi e parametri macchina modificabili via variabili d'ambiente. * **Visualizzazione 3D**: Anteprima del file STL caricato tramite Three.js.
* **Docker Ready**: Tutto containerizzato per un facile deployment. * **Multi-Profilo**: Supporto per diverse stampanti, materiali e profili di processo.
## Stack Tecnologico
- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway.
- **Frontend**: Angular 19, Angular Material, Three.js.
- **Slicer**: OrcaSlicer (invocato via CLI).
## Prerequisiti ## Prerequisiti
* Docker Desktop & Docker Compose installati. * **Java 21** installato.
* **Node.js 22** e **npm** installati.
* **PostgreSQL** attivo.
* **OrcaSlicer** installato sul sistema.
## Avvio Rapido ## Avvio Rapido
1. Clona il repository. ### 1. Database
2. Esegui lo script di avvio o docker-compose: Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway.
```bash
docker-compose up --build
```
*Nota: La prima build impiegherà alcuni minuti per scaricare OrcaSlicer (~200MB) e compilare il Frontend.*
3. Accedi all'applicazione: ### 2. Backend
* **Frontend**: [http://localhost](http://localhost) Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`.
* **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
## Configurazione Prezzi
Puoi modificare i prezzi nel file `docker-compose.yml` (sezione `environment` del servizio backend):
* `FILAMENT_COST_PER_KG`: Costo filamento al kg (es. 25.0).
* `MACHINE_COST_PER_HOUR`: Costo orario macchina (ammortamento/manutenzione).
* `ENERGY_COST_PER_KWH`: Costo energia elettrica.
* `MARKUP_PERCENT`: Margine di profitto percentuale (es. 20 = +20%).
## Struttura del Progetto
* `/backend`: API Python FastAPI. Include Dockerfile che scarica OrcaSlicer AppImage.
* `/frontend`: Applicazione Angular 19+ con Material Design.
* `/backend/profiles`: Contiene i profili di slicing (.ini). Attualmente configurato per una stima generica simil-Bambu Lab A1.
## Troubleshooting
### Errore Download OrcaSlicer
Se la build del backend fallisce durante il download di `OrcaSlicer.AppImage`, verifica la tua connessione internet o aggiorna l'URL nel `backend/Dockerfile`.
### Slicing Fallito (Costo 0 o Errore)
Se l'API ritorna errore o valori nulli:
1. Controlla che il file STL sia valido (manifold).
2. Controlla i log del backend: `docker logs print-calculator-backend`.
## Sviluppo Locale (Senza Docker)
**Backend**:
Richiede Linux (o WSL2) per eseguire l'AppImage di OrcaSlicer.
```bash ```bash
cd backend cd backend
pip install -r requirements.txt ./gradlew bootRun
# Assicurati di avere OrcaSlicer installato e nel PATH o aggiorna SLICER_PATH in slicer.py
uvicorn main:app --reload
``` ```
**Frontend**: ### 3. Frontend
```bash ```bash
cd frontend cd frontend
npm install npm install
npm start npm start
``` ```
Accedi a [http://localhost:4200](http://localhost:4200).
## Configurazione Prezzi
I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tabelle nel database:
- `pricing_policy`: Definisce markup, fee fissi e costi elettrici.
- `pricing_policy_machine_hour_tier`: Definisce i costi orari delle macchine in base alla durata della stampa.
- `printer_machine`: Anagrafica stampanti e consumi energetici.
- `filament_material_type` / `filament_variant`: Listino prezzi materiali.
## Struttura del Progetto
* `/backend`: API Spring Boot.
* `/frontend`: Applicazione Angular.
* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer.
## Troubleshooting
### Percorso OrcaSlicer
Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno).
### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.

View File

@@ -11,7 +11,7 @@ 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
# 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 \
p7zip-full \ p7zip-full \
@@ -20,6 +20,14 @@ RUN apt-get update && apt-get install -y \
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

View File

@@ -25,10 +25,21 @@ 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-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'xyz.capybara:clamav-client:2.1.2'
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'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37'
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
} }
tasks.named('test') { tasks.named('test') {

View File

@@ -2,8 +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.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication @SpringBootApplication
@EnableTransactionManagement
@EnableScheduling
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -11,8 +11,16 @@ public class CorsConfig implements WebMvcConfigurer {
@Override @Override
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") registry.addMapping("/**")
.allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1") .allowedOrigins(
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") "http://localhost",
"http://localhost:4200",
"http://localhost:80",
"http://127.0.0.1",
"https://dev.3d-fab.ch",
"https://int.3d-fab.ch",
"https://3d-fab.ch"
)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*") .allowedHeaders("*")
.allowCredentials(true); .allowCredentials(true);
} }

View File

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

View File

@@ -65,6 +65,20 @@ public class OptionsController {
.filter(m -> m != null) .filter(m -> m != null)
.collect(Collectors.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) // 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"),

View File

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

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,22 +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 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) { 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.profileManager = profileManager;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -44,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 ...
@@ -72,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) {
@@ -81,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")
@@ -89,12 +103,13 @@ 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();
} }
@@ -105,23 +120,74 @@ public class QuoteController {
// 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

@@ -0,0 +1,468 @@
package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import java.util.logging.Logger;
@RestController
@RequestMapping("/api/quote-sessions")
public class QuoteSessionController {
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator;
private final ProfileManager profileManager;
private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.StorageService storageService;
// Defaults
private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator,
ProfileManager profileManager,
PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.StorageService storageService) {
this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator;
this.profileManager = profileManager;
this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo;
this.storageService = storageService;
}
// 1. Start a new empty session
@PostMapping(value = "")
@Transactional
public ResponseEntity<QuoteSession> createSession() {
QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE");
session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated?
// For now set safe defaults
session.setMaterialCode("pla_basic");
session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO);
session = sessionRepo.save(session);
return ResponseEntity.ok(session);
}
// 2. Add item to existing session
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<QuoteLineItem> addItemToExistingSession(
@PathVariable UUID id,
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings,
@RequestPart("file") MultipartFile file
) throws IOException {
QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found"));
QuoteLineItem item = addItemToSession(session, file, settings);
return ResponseEntity.ok(item);
}
// Helper to add item
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) throw new IOException("File is empty");
// 1. Define Persistent Storage Path
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf("."))
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
// Save file
storageService.store(file, relativePath);
// Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
com.printcalculator.model.StlShiftResult shift = null;
try {
// Apply Basic/Advanced Logic
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()));
}
// 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile();
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
}
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
}
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
if (settings.getMaterial() != null) {
if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA";
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
// Update Session Material
session.setMaterialCode(settings.getMaterial());
} else {
// Fallback if null?
session.setMaterialCode("pla_basic");
}
// Update Session Settings for Persistence
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
// Save session updates
sessionRepo.save(session);
String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process
// "standard" -> "0.20mm Standard @BBL A1"
// "draft" -> "0.28mm Extra Draft @BBL A1"
// "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca)
// Let's use robust defaults or simple overrides
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
}
// Build overrides map from settings
// Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) {
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
// If enabled, use a more permissive threshold (45 deg) by default
// to avoid expensive supports on things that don't strictly need them
if (settings.getSupportsEnabled()) {
processOverrides.put("support_threshold_angle", "45");
}
}
Map<String, String> machineOverrides = new HashMap<>();
if (settings.getNozzleDiameter() != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
}
// 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
sliceInput,
machineProfile,
filamentProfile,
processProfile,
machineOverrides, // machine overrides
processOverrides
);
// 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 6. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setQuantity(1);
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown);
// Dimensions from STL
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
return lineItemRepo.save(item);
} catch (Exception e) {
// Cleanup if failed
try {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
throw e;
} finally {
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
}
}
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
switch (quality) {
case "draft":
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "high":
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "standard":
default:
settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
}
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
}
}
// 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}")
@Transactional
public ResponseEntity<QuoteLineItem> updateLineItem(
@PathVariable UUID lineItemId,
@RequestBody Map<String, Object> updates
) {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (updates.containsKey("quantity")) {
item.setQuantity((Integer) updates.get("quantity"));
}
if (updates.containsKey("color_code")) {
item.setColorCode((String) updates.get("color_code"));
}
// Recalculate price if needed?
// For now, unit price is fixed in mock. Total is calculated on GET.
item.setUpdatedAt(OffsetDateTime.now());
return ResponseEntity.ok(lineItemRepo.save(item));
}
// 4. Delete Line Item
@DeleteMapping("/{sessionId}/line-items/{lineItemId}")
@Transactional
public ResponseEntity<Void> deleteLineItem(
@PathVariable UUID sessionId,
@PathVariable UUID lineItemId
) {
// Verify item belongs to session?
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
lineItemRepo.delete(item);
return ResponseEntity.noContent().build();
}
// 5. Get Session (Session + Items + Total)
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found"));
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
// Calculate Totals
BigDecimal itemsTotal = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
itemsTotal = itemsTotal.add(lineTotal);
}
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal grandTotal = itemsTotal.add(setupFee);
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", items);
response.put("itemsTotalChf", itemsTotal);
response.put("grandTotalChf", grandTotal);
return ResponseEntity.ok(response);
}
// 6. Download Line Item Content
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent(
@PathVariable UUID sessionId,
@PathVariable UUID lineItemId
) throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
if (item.getStoredPath() == null) {
return ResponseEntity.notFound().build();
}
Path path = Paths.get(item.getStoredPath());
// 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();
}
org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"")
.body(resource);
}
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.dto;
import lombok.Data;
@Data
public class AddressDto {
private String firstName;
private String lastName;
private String companyName;
private String contactPerson;
private String addressLine1;
private String addressLine2;
private String zip;
private String city;
private String countryCode;
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.dto;
import lombok.Data;
@Data
public class CreateOrderRequest {
private CustomerDto customer;
private AddressDto billingAddress;
private AddressDto shippingAddress;
private boolean shippingSameAsBilling;
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.dto;
import lombok.Data;
@Data
public class CustomerDto {
private String email;
private String phone;
private String customerType; // "PRIVATE", "BUSINESS"
}

View File

@@ -0,0 +1,74 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class OrderDto {
private UUID id;
private String status;
private String customerEmail;
private String customerPhone;
private String billingCustomerType;
private AddressDto billingAddress;
private AddressDto shippingAddress;
private Boolean shippingSameAsBilling;
private String currency;
private BigDecimal setupCostChf;
private BigDecimal shippingCostChf;
private BigDecimal discountChf;
private BigDecimal subtotalChf;
private BigDecimal totalChf;
private OffsetDateTime createdAt;
private List<OrderItemDto> items;
// Getters and Setters
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 getCustomerEmail() { return customerEmail; }
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
public String getCustomerPhone() { return customerPhone; }
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
public String getBillingCustomerType() { return billingCustomerType; }
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
public AddressDto getBillingAddress() { return billingAddress; }
public void setBillingAddress(AddressDto billingAddress) { this.billingAddress = billingAddress; }
public AddressDto getShippingAddress() { return shippingAddress; }
public void setShippingAddress(AddressDto shippingAddress) { this.shippingAddress = shippingAddress; }
public Boolean getShippingSameAsBilling() { return shippingSameAsBilling; }
public void setShippingSameAsBilling(Boolean shippingSameAsBilling) { this.shippingSameAsBilling = shippingSameAsBilling; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
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 getDiscountChf() { return discountChf; }
public void setDiscountChf(BigDecimal discountChf) { this.discountChf = discountChf; }
public BigDecimal getSubtotalChf() { return subtotalChf; }
public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; }
public BigDecimal getTotalChf() { return totalChf; }
public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public List<OrderItemDto> getItems() { return items; }
public void setItems(List<OrderItemDto> items) { this.items = items; }
}

View File

@@ -0,0 +1,44 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public class OrderItemDto {
private UUID id;
private String originalFilename;
private String materialCode;
private String colorCode;
private Integer quantity;
private Integer printTimeSeconds;
private BigDecimal materialGrams;
private BigDecimal unitPriceChf;
private BigDecimal lineTotalChf;
// Getters and Setters
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 getMaterialCode() { return materialCode; }
public void setMaterialCode(String materialCode) { this.materialCode = materialCode; }
public String getColorCode() { return colorCode; }
public void setColorCode(String colorCode) { this.colorCode = colorCode; }
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
public Integer getPrintTimeSeconds() { return printTimeSeconds; }
public void setPrintTimeSeconds(Integer printTimeSeconds) { this.printTimeSeconds = printTimeSeconds; }
public BigDecimal getMaterialGrams() { return materialGrams; }
public void setMaterialGrams(BigDecimal materialGrams) { this.materialGrams = materialGrams; }
public BigDecimal getUnitPriceChf() { return unitPriceChf; }
public void setUnitPriceChf(BigDecimal unitPriceChf) { this.unitPriceChf = unitPriceChf; }
public BigDecimal getLineTotalChf() { return lineTotalChf; }
public void setLineTotalChf(BigDecimal lineTotalChf) { this.lineTotalChf = lineTotalChf; }
}

View File

@@ -0,0 +1,24 @@
package com.printcalculator.dto;
import lombok.Data;
@Data
public class PrintSettingsDto {
// Mode: "BASIC" or "ADVANCED"
private String complexityMode;
// Common
private String material; // e.g. "PLA", "PETG"
private String color; // e.g. "White", "#FFFFFF"
// Basic Mode
private String quality; // "draft", "standard", "high"
// Advanced Mode (Optional in Basic)
private Double layerHeight;
private Double infillDensity;
private String infillPattern;
private Boolean supportsEnabled = true;
private Double nozzleDiameter;
private String notes;
}

View File

@@ -0,0 +1,15 @@
package com.printcalculator.dto;
import lombok.Data;
@Data
public class QuoteRequestDto {
private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE"
private String customerType; // "PRIVATE" or "BUSINESS"
private String email;
private String phone;
private String name;
private String companyName;
private String contactPerson;
private String message;
}

View File

@@ -0,0 +1,149 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "custom_quote_requests", indexes = {@Index(name = "ix_custom_quote_requests_status",
columnList = "status")})
public class CustomQuoteRequest {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "request_id", nullable = false)
private UUID id;
@Column(name = "request_type", nullable = false, length = Integer.MAX_VALUE)
private String requestType;
@Column(name = "customer_type", nullable = false, length = Integer.MAX_VALUE)
private String customerType;
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
private String email;
@Column(name = "phone", length = Integer.MAX_VALUE)
private String phone;
@Column(name = "name", length = Integer.MAX_VALUE)
private String name;
@Column(name = "company_name", length = Integer.MAX_VALUE)
private String companyName;
@Column(name = "contact_person", length = Integer.MAX_VALUE)
private String contactPerson;
@Column(name = "message", nullable = false, length = Integer.MAX_VALUE)
private String message;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
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;
}
}

View File

@@ -0,0 +1,121 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "custom_quote_request_attachments", indexes = {@Index(name = "ix_custom_quote_attachments_request",
columnList = "request_id")})
public class CustomQuoteRequestAttachment {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "attachment_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "request_id", nullable = false)
private CustomQuoteRequest request;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath;
@Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE)
private String storedFilename;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "mime_type", length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "sha256_hex", length = Integer.MAX_VALUE)
private String sha256Hex;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public CustomQuoteRequest getRequest() {
return request;
}
public void setRequest(CustomQuoteRequest request) {
this.request = request;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getStoredRelativePath() {
return storedRelativePath;
}
public void setStoredRelativePath(String storedRelativePath) {
this.storedRelativePath = storedRelativePath;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public void setCustomQuoteRequest(CustomQuoteRequest request) {
}
}

View File

@@ -0,0 +1,126 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "customer_id", nullable = false)
private UUID id;
@Column(name = "customer_type", nullable = false, length = Integer.MAX_VALUE)
private String customerType;
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
private String email;
@Column(name = "phone", length = Integer.MAX_VALUE)
private String phone;
@Column(name = "first_name", length = Integer.MAX_VALUE)
private String firstName;
@Column(name = "last_name", length = Integer.MAX_VALUE)
private String lastName;
@Column(name = "company_name", length = Integer.MAX_VALUE)
private String companyName;
@Column(name = "contact_person", length = Integer.MAX_VALUE)
private String contactPerson;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
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 getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
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 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;
}
}

View File

@@ -0,0 +1,414 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "orders", indexes = {@Index(name = "ix_orders_status",
columnList = "status")})
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "order_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "source_quote_session_id")
private QuoteSession sourceQuoteSession;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(name = "customer_email", nullable = false, length = Integer.MAX_VALUE)
private String customerEmail;
@Column(name = "customer_phone", length = Integer.MAX_VALUE)
private String customerPhone;
@Column(name = "billing_customer_type", nullable = false, length = Integer.MAX_VALUE)
private String billingCustomerType;
@Column(name = "billing_first_name", length = Integer.MAX_VALUE)
private String billingFirstName;
@Column(name = "billing_last_name", length = Integer.MAX_VALUE)
private String billingLastName;
@Column(name = "billing_company_name", length = Integer.MAX_VALUE)
private String billingCompanyName;
@Column(name = "billing_contact_person", length = Integer.MAX_VALUE)
private String billingContactPerson;
@Column(name = "billing_address_line1", nullable = false, length = Integer.MAX_VALUE)
private String billingAddressLine1;
@Column(name = "billing_address_line2", length = Integer.MAX_VALUE)
private String billingAddressLine2;
@Column(name = "billing_zip", nullable = false, length = Integer.MAX_VALUE)
private String billingZip;
@Column(name = "billing_city", nullable = false, length = Integer.MAX_VALUE)
private String billingCity;
@ColumnDefault("'CH'")
@Column(name = "billing_country_code", nullable = false, length = 2)
private String billingCountryCode;
@ColumnDefault("true")
@Column(name = "shipping_same_as_billing", nullable = false)
private Boolean shippingSameAsBilling;
@Column(name = "shipping_first_name", length = Integer.MAX_VALUE)
private String shippingFirstName;
@Column(name = "shipping_last_name", length = Integer.MAX_VALUE)
private String shippingLastName;
@Column(name = "shipping_company_name", length = Integer.MAX_VALUE)
private String shippingCompanyName;
@Column(name = "shipping_contact_person", length = Integer.MAX_VALUE)
private String shippingContactPerson;
@Column(name = "shipping_address_line1", length = Integer.MAX_VALUE)
private String shippingAddressLine1;
@Column(name = "shipping_address_line2", length = Integer.MAX_VALUE)
private String shippingAddressLine2;
@Column(name = "shipping_zip", length = Integer.MAX_VALUE)
private String shippingZip;
@Column(name = "shipping_city", length = Integer.MAX_VALUE)
private String shippingCity;
@Column(name = "shipping_country_code", length = 2)
private String shippingCountryCode;
@ColumnDefault("'CHF'")
@Column(name = "currency", nullable = false, length = 3)
private String currency;
@ColumnDefault("0.00")
@Column(name = "setup_cost_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal setupCostChf;
@ColumnDefault("0.00")
@Column(name = "shipping_cost_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal shippingCostChf;
@ColumnDefault("0.00")
@Column(name = "discount_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal discountChf;
@ColumnDefault("0.00")
@Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal subtotalChf;
@ColumnDefault("0.00")
@Column(name = "total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal totalChf;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@Column(name = "paid_at")
private OffsetDateTime paidAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public QuoteSession getSourceQuoteSession() {
return sourceQuoteSession;
}
public void setSourceQuoteSession(QuoteSession sourceQuoteSession) {
this.sourceQuoteSession = sourceQuoteSession;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Customer getCustomer() {
return customer;
}
public void setCustomer(Customer customer) {
this.customer = customer;
}
public String getCustomerEmail() {
return customerEmail;
}
public void setCustomerEmail(String customerEmail) {
this.customerEmail = customerEmail;
}
public String getCustomerPhone() {
return customerPhone;
}
public void setCustomerPhone(String customerPhone) {
this.customerPhone = customerPhone;
}
public String getBillingCustomerType() {
return billingCustomerType;
}
public void setBillingCustomerType(String billingCustomerType) {
this.billingCustomerType = billingCustomerType;
}
public String getBillingFirstName() {
return billingFirstName;
}
public void setBillingFirstName(String billingFirstName) {
this.billingFirstName = billingFirstName;
}
public String getBillingLastName() {
return billingLastName;
}
public void setBillingLastName(String billingLastName) {
this.billingLastName = billingLastName;
}
public String getBillingCompanyName() {
return billingCompanyName;
}
public void setBillingCompanyName(String billingCompanyName) {
this.billingCompanyName = billingCompanyName;
}
public String getBillingContactPerson() {
return billingContactPerson;
}
public void setBillingContactPerson(String billingContactPerson) {
this.billingContactPerson = billingContactPerson;
}
public String getBillingAddressLine1() {
return billingAddressLine1;
}
public void setBillingAddressLine1(String billingAddressLine1) {
this.billingAddressLine1 = billingAddressLine1;
}
public String getBillingAddressLine2() {
return billingAddressLine2;
}
public void setBillingAddressLine2(String billingAddressLine2) {
this.billingAddressLine2 = billingAddressLine2;
}
public String getBillingZip() {
return billingZip;
}
public void setBillingZip(String billingZip) {
this.billingZip = billingZip;
}
public String getBillingCity() {
return billingCity;
}
public void setBillingCity(String billingCity) {
this.billingCity = billingCity;
}
public String getBillingCountryCode() {
return billingCountryCode;
}
public void setBillingCountryCode(String billingCountryCode) {
this.billingCountryCode = billingCountryCode;
}
public Boolean getShippingSameAsBilling() {
return shippingSameAsBilling;
}
public void setShippingSameAsBilling(Boolean shippingSameAsBilling) {
this.shippingSameAsBilling = shippingSameAsBilling;
}
public String getShippingFirstName() {
return shippingFirstName;
}
public void setShippingFirstName(String shippingFirstName) {
this.shippingFirstName = shippingFirstName;
}
public String getShippingLastName() {
return shippingLastName;
}
public void setShippingLastName(String shippingLastName) {
this.shippingLastName = shippingLastName;
}
public String getShippingCompanyName() {
return shippingCompanyName;
}
public void setShippingCompanyName(String shippingCompanyName) {
this.shippingCompanyName = shippingCompanyName;
}
public String getShippingContactPerson() {
return shippingContactPerson;
}
public void setShippingContactPerson(String shippingContactPerson) {
this.shippingContactPerson = shippingContactPerson;
}
public String getShippingAddressLine1() {
return shippingAddressLine1;
}
public void setShippingAddressLine1(String shippingAddressLine1) {
this.shippingAddressLine1 = shippingAddressLine1;
}
public String getShippingAddressLine2() {
return shippingAddressLine2;
}
public void setShippingAddressLine2(String shippingAddressLine2) {
this.shippingAddressLine2 = shippingAddressLine2;
}
public String getShippingZip() {
return shippingZip;
}
public void setShippingZip(String shippingZip) {
this.shippingZip = shippingZip;
}
public String getShippingCity() {
return shippingCity;
}
public void setShippingCity(String shippingCity) {
this.shippingCity = shippingCity;
}
public String getShippingCountryCode() {
return shippingCountryCode;
}
public void setShippingCountryCode(String shippingCountryCode) {
this.shippingCountryCode = shippingCountryCode;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
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 getDiscountChf() {
return discountChf;
}
public void setDiscountChf(BigDecimal discountChf) {
this.discountChf = discountChf;
}
public BigDecimal getSubtotalChf() {
return subtotalChf;
}
public void setSubtotalChf(BigDecimal subtotalChf) {
this.subtotalChf = subtotalChf;
}
public BigDecimal getTotalChf() {
return totalChf;
}
public void setTotalChf(BigDecimal totalChf) {
this.totalChf = totalChf;
}
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 OffsetDateTime getPaidAt() {
return paidAt;
}
public void setPaidAt(OffsetDateTime paidAt) {
this.paidAt = paidAt;
}
}

View File

@@ -0,0 +1,198 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "order_items", indexes = {@Index(name = "ix_order_items_order",
columnList = "order_id")})
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "order_item_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath;
@Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE)
private String storedFilename;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "mime_type", length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "sha256_hex", length = Integer.MAX_VALUE)
private String sha256Hex;
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode;
@Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode;
@ColumnDefault("1")
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "print_time_seconds")
private Integer printTimeSeconds;
@Column(name = "material_grams", precision = 12, scale = 2)
private BigDecimal materialGrams;
@Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal unitPriceChf;
@Column(name = "line_total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal lineTotalChf;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Order getOrder() {
return order;
}
public void setOrder(Order order) {
this.order = order;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getStoredRelativePath() {
return storedRelativePath;
}
public void setStoredRelativePath(String storedRelativePath) {
this.storedRelativePath = storedRelativePath;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getColorCode() {
return colorCode;
}
public void setColorCode(String colorCode) {
this.colorCode = colorCode;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public Integer getPrintTimeSeconds() {
return printTimeSeconds;
}
public void setPrintTimeSeconds(Integer printTimeSeconds) {
this.printTimeSeconds = printTimeSeconds;
}
public BigDecimal getMaterialGrams() {
return materialGrams;
}
public void setMaterialGrams(BigDecimal materialGrams) {
this.materialGrams = materialGrams;
}
public BigDecimal getUnitPriceChf() {
return unitPriceChf;
}
public void setUnitPriceChf(BigDecimal unitPriceChf) {
this.unitPriceChf = unitPriceChf;
}
public BigDecimal getLineTotalChf() {
return lineTotalChf;
}
public void setLineTotalChf(BigDecimal lineTotalChf) {
this.lineTotalChf = lineTotalChf;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,146 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "payments", indexes = {
@Index(name = "ix_payments_order",
columnList = "order_id"),
@Index(name = "ix_payments_reference",
columnList = "payment_reference")})
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "payment_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@Column(name = "method", nullable = false, length = Integer.MAX_VALUE)
private String method;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@ColumnDefault("'CHF'")
@Column(name = "currency", nullable = false, length = 3)
private String currency;
@Column(name = "amount_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal amountChf;
@Column(name = "payment_reference", length = Integer.MAX_VALUE)
private String paymentReference;
@Column(name = "provider_transaction_id", length = Integer.MAX_VALUE)
private String providerTransactionId;
@Column(name = "qr_payload", length = Integer.MAX_VALUE)
private String qrPayload;
@ColumnDefault("now()")
@Column(name = "initiated_at", nullable = false)
private OffsetDateTime initiatedAt;
@Column(name = "received_at")
private OffsetDateTime receivedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Order getOrder() {
return order;
}
public void setOrder(Order order) {
this.order = order;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
public BigDecimal getAmountChf() {
return amountChf;
}
public void setAmountChf(BigDecimal amountChf) {
this.amountChf = amountChf;
}
public String getPaymentReference() {
return paymentReference;
}
public void setPaymentReference(String paymentReference) {
this.paymentReference = paymentReference;
}
public String getProviderTransactionId() {
return providerTransactionId;
}
public void setProviderTransactionId(String providerTransactionId) {
this.providerTransactionId = providerTransactionId;
}
public String getQrPayload() {
return qrPayload;
}
public void setQrPayload(String qrPayload) {
this.qrPayload = qrPayload;
}
public OffsetDateTime getInitiatedAt() {
return initiatedAt;
}
public void setInitiatedAt(OffsetDateTime initiatedAt) {
this.initiatedAt = initiatedAt;
}
public OffsetDateTime getReceivedAt() {
return receivedAt;
}
public void setReceivedAt(OffsetDateTime receivedAt) {
this.receivedAt = 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

@@ -0,0 +1,215 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "quote_line_items", indexes = {@Index(name = "ix_quote_line_items_session",
columnList = "quote_session_id")})
public class QuoteLineItem {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "quote_line_item_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "quote_session_id", nullable = false)
@com.fasterxml.jackson.annotation.JsonIgnore
private QuoteSession quoteSession;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@ColumnDefault("1")
@Column(name = "quantity", nullable = false)
private Integer quantity;
@Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode;
@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 = "print_time_seconds")
private Integer printTimeSeconds;
@Column(name = "material_grams", precision = 12, scale = 2)
private BigDecimal materialGrams;
@Column(name = "unit_price_chf", precision = 12, scale = 2)
private BigDecimal unitPriceChf;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "pricing_breakdown")
private Map<String, Object> pricingBreakdown;
@Column(name = "error_message", length = Integer.MAX_VALUE)
private String errorMessage;
@Column(name = "stored_path", length = Integer.MAX_VALUE)
private String storedPath;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public QuoteSession getQuoteSession() {
return quoteSession;
}
public void setQuoteSession(QuoteSession quoteSession) {
this.quoteSession = quoteSession;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public String getColorCode() {
return colorCode;
}
public void setColorCode(String colorCode) {
this.colorCode = colorCode;
}
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 Integer getPrintTimeSeconds() {
return printTimeSeconds;
}
public void setPrintTimeSeconds(Integer printTimeSeconds) {
this.printTimeSeconds = printTimeSeconds;
}
public BigDecimal getMaterialGrams() {
return materialGrams;
}
public void setMaterialGrams(BigDecimal materialGrams) {
this.materialGrams = materialGrams;
}
public BigDecimal getUnitPriceChf() {
return unitPriceChf;
}
public void setUnitPriceChf(BigDecimal unitPriceChf) {
this.unitPriceChf = unitPriceChf;
}
public Map<String, Object> getPricingBreakdown() {
return pricingBreakdown;
}
public void setPricingBreakdown(Map<String, Object> pricingBreakdown) {
this.pricingBreakdown = pricingBreakdown;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getStoredPath() {
return storedPath;
}
public void setStoredPath(String storedPath) {
this.storedPath = storedPath;
}
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;
}
}

View File

@@ -0,0 +1,176 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "quote_sessions", indexes = {
@Index(name = "ix_quote_sessions_status",
columnList = "status"),
@Index(name = "ix_quote_sessions_expires_at",
columnList = "expires_at")})
public class QuoteSession {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "quote_session_id", nullable = false)
private UUID id;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE)
private String pricingVersion;
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode;
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 6, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "infill_percent")
private Integer infillPercent;
@ColumnDefault("false")
@Column(name = "supports_enabled", nullable = false)
private Boolean supportsEnabled;
@Column(name = "notes", length = Integer.MAX_VALUE)
private String notes;
@ColumnDefault("0.00")
@Column(name = "setup_cost_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal setupCostChf;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "expires_at", nullable = false)
private OffsetDateTime expiresAt;
@Column(name = "converted_order_id")
private UUID convertedOrderId;
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 getPricingVersion() {
return pricingVersion;
}
public void setPricingVersion(String pricingVersion) {
this.pricingVersion = pricingVersion;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public BigDecimal getSetupCostChf() {
return setupCostChf;
}
public void setSetupCostChf(BigDecimal setupCostChf) {
this.setupCostChf = setupCostChf;
}
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;
}
}

View File

@@ -0,0 +1,71 @@
package com.printcalculator.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.HashMap;
import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(StorageException.class)
public ResponseEntity<?> handleStorageException(StorageException exc) {
// Log the full exception for internal debugging
log.error("Storage Exception occurred", exc);
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);
}
// Generic fallback for other storage errors to avoid leaking internal paths/details
response.put("error", "Storage Operation Failed");
response.put("message", "Unable to process the file upload.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
response.put("message", "The uploaded file exceeds the maximum allowed size.");
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
}
@ExceptionHandler(ModelTooLargeException.class)
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "Model too large");
response.put("code", "MODEL_TOO_LARGE");
response.put("message", String.format(
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
));
response.put("model_x_mm", formatMm(exc.getModelX()));
response.put("model_y_mm", formatMm(exc.getModelY()));
response.put("model_z_mm", formatMm(exc.getModelZ()));
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
return ResponseEntity.unprocessableEntity().body(response);
}
private String formatMm(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}

View File

@@ -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

@@ -0,0 +1,12 @@
package com.printcalculator.exception;
public class StorageException extends RuntimeException {
public StorageException(String message) {
super(message);
}
public StorageException(String message, Throwable cause) {
super(message, cause);
}
}

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

@@ -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

@@ -0,0 +1,9 @@
package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
}

View File

@@ -0,0 +1,9 @@
package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface CustomQuoteRequestRepository extends JpaRepository<CustomQuoteRequest, UUID> {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
Optional<Customer> findByEmail(String email);
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariantStockKg;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FilamentVariantStockKgRepository extends JpaRepository<FilamentVariantStockKg, Long> {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.OrderItem;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId);
}

View File

@@ -0,0 +1,9 @@
package com.printcalculator.repository;
import com.printcalculator.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> {
}

View File

@@ -0,0 +1,9 @@
package com.printcalculator.repository;
import com.printcalculator.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterFleetCurrent;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PrinterFleetCurrentRepository extends JpaRepository<PrinterFleetCurrent, Long> {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.QuoteLineItem;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.QuoteSession;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
}

View File

@@ -0,0 +1,64 @@
package com.printcalculator.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import xyz.capybara.clamav.ClamavClient;
import xyz.capybara.clamav.commands.scan.result.ScanResult;
import java.io.InputStream;
import java.util.Collection;
import java.util.Map;
@Service
public class ClamAVService {
private static final Logger logger = LoggerFactory.getLogger(ClamAVService.class);
private final ClamavClient clamavClient;
private final boolean enabled;
public ClamAVService(
@Value("${clamav.host:clamav}") String host,
@Value("${clamav.port:3310}") int port,
@Value("${clamav.enabled:false}") boolean 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;
try {
client = new ClamavClient(host, port);
} catch (Exception e) {
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
}
this.clamavClient = client;
}
public boolean scan(InputStream inputStream) {
if (!enabled || clamavClient == null) {
return true;
}
try {
ScanResult result = clamavClient.scan(inputStream);
if (result instanceof ScanResult.OK) {
return true;
} else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("VIRUS DETECTED: {}", viruses);
return false;
} else {
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
return true;
}
} catch (Exception e) {
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
return true;
}
}
}

View File

@@ -0,0 +1,94 @@
package com.printcalculator.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.printcalculator.exception.StorageException;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
@Service
public class FileSystemStorageService implements StorageService {
private final Path rootLocation;
private final ClamAVService clamAVService;
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
this.rootLocation = Paths.get(storageLocation);
this.clamAVService = clamAVService;
}
@Override
public void init() {
try {
Files.createDirectories(rootLocation);
} catch (IOException e) {
throw new StorageException("Could not initialize storage", e);
}
}
@Override
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
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
Files.createDirectories(destinationFile.getParent());
file.transferTo(destinationFile.toFile());
// 2. Scansiona il file appena salvato aprendo un nuovo stream
try (InputStream inputStream = new FileInputStream(destinationFile.toFile())) {
if (!clamAVService.scan(inputStream)) {
// Se infetto, cancella il file e solleva eccezione
Files.deleteIfExists(destinationFile);
throw new StorageException("File rejected by antivirus scanner.");
}
} catch (Exception e) {
if (e instanceof StorageException) throw e;
// Se l'antivirus fallisce per motivi tecnici, lasciamo il file (fail-open come concordato)
}
}
@Override
public void store(Path source, Path destinationRelativePath) throws IOException {
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.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void delete(Path path) throws IOException {
Path file = rootLocation.resolve(path);
Files.deleteIfExists(file);
}
@Override
public Resource loadAsResource(Path path) throws IOException {
try {
Path file = rootLocation.resolve(path);
Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) {
return resource;
} else {
throw new RuntimeException("Could not read file: " + path);
}
} catch (MalformedURLException e) {
throw new RuntimeException("Could not read file: " + path, e);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,300 @@
package com.printcalculator.service;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*;
import com.printcalculator.repository.CustomerRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
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.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class OrderService {
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final CustomerRepository customerRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.customerRepo = customerRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
}
@Transactional
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
if (session.getConvertedOrderId() != null) {
throw new IllegalStateException("Quote session already converted to order");
}
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
.orElseGet(() -> {
Customer newC = new Customer();
newC.setEmail(request.getCustomer().getEmail());
newC.setCustomerType(request.getCustomer().getCustomerType());
newC.setCreatedAt(OffsetDateTime.now());
newC.setUpdatedAt(OffsetDateTime.now());
return customerRepo.save(newC);
});
customer.setPhone(request.getCustomer().getPhone());
customer.setCustomerType(request.getCustomer().getCustomerType());
customer.setUpdatedAt(OffsetDateTime.now());
customerRepo.save(customer);
Order order = new Order();
order.setSourceQuoteSession(session);
order.setCustomer(customer);
order.setCustomerEmail(request.getCustomer().getEmail());
order.setCustomerPhone(request.getCustomer().getPhone());
order.setStatus("PENDING_PAYMENT");
order.setCreatedAt(OffsetDateTime.now());
order.setUpdatedAt(OffsetDateTime.now());
order.setCurrency("CHF");
order.setBillingCustomerType(request.getCustomer().getCustomerType());
if (request.getBillingAddress() != null) {
order.setBillingFirstName(request.getBillingAddress().getFirstName());
order.setBillingLastName(request.getBillingAddress().getLastName());
order.setBillingCompanyName(request.getBillingAddress().getCompanyName());
order.setBillingContactPerson(request.getBillingAddress().getContactPerson());
order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1());
order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2());
order.setBillingZip(request.getBillingAddress().getZip());
order.setBillingCity(request.getBillingAddress().getCity());
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
}
order.setShippingSameAsBilling(request.isShippingSameAsBilling());
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
order.setShippingFirstName(request.getShippingAddress().getFirstName());
order.setShippingLastName(request.getShippingAddress().getLastName());
order.setShippingCompanyName(request.getShippingAddress().getCompanyName());
order.setShippingContactPerson(request.getShippingAddress().getContactPerson());
order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1());
order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2());
order.setShippingZip(request.getShippingAddress().getZip());
order.setShippingCity(request.getShippingAddress().getCity());
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
} else {
order.setShippingFirstName(order.getBillingFirstName());
order.setShippingLastName(order.getBillingLastName());
order.setShippingCompanyName(order.getBillingCompanyName());
order.setShippingContactPerson(order.getBillingContactPerson());
order.setShippingAddressLine1(order.getBillingAddressLine1());
order.setShippingAddressLine2(order.getBillingAddressLine2());
order.setShippingZip(order.getBillingZip());
order.setShippingCity(order.getBillingCity());
order.setShippingCountryCode(order.getBillingCountryCode());
}
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
BigDecimal subtotal = BigDecimal.ZERO;
order.setSubtotalChf(BigDecimal.ZERO);
order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
order.setShippingCostChf(BigDecimal.valueOf(9.00));
order = orderRepo.save(order);
List<OrderItem> savedItems = new ArrayList<>();
for (QuoteLineItem qItem : quoteItems) {
OrderItem oItem = new OrderItem();
oItem.setOrder(order);
oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity());
oItem.setColorCode(qItem.getColorCode());
oItem.setMaterialCode(session.getMaterialCode());
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
oItem.setMaterialGrams(qItem.getMaterialGrams());
UUID fileUuid = UUID.randomUUID();
String ext = getExtension(qItem.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext;
oItem.setStoredFilename(storedFilename);
oItem.setStoredRelativePath("PENDING");
oItem.setMimeType("application/octet-stream");
oItem.setCreatedAt(OffsetDateTime.now());
oItem = orderItemRepo.save(oItem);
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath);
if (qItem.getStoredPath() != null) {
try {
Path sourcePath = Paths.get(qItem.getStoredPath());
if (Files.exists(sourcePath)) {
storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath));
}
} catch (IOException e) {
e.printStackTrace();
}
}
oItem = orderItemRepo.save(oItem);
savedItems.add(oItem);
subtotal = subtotal.add(oItem.getLineTotalChf());
}
order.setSubtotalChf(subtotal);
if (order.getShippingCostChf() == null) {
order.setShippingCostChf(BigDecimal.valueOf(9.00));
}
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total);
session.setConvertedOrderId(order.getId());
session.setStatus("CONVERTED");
quoteSessionRepo.save(session);
// Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems);
return orderRepo.save(order);
}
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
try {
// 1. Generate QR Bill
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
String qrBillSvg = new String(qrBillSvgBytes, 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);
}
}
// 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) {
e.printStackTrace();
// Don't fail the order if document generation fails, but log it
// TODO: Better error handling
}
}
private void saveFileBytes(byte[] content, String relativePath) {
// Since StorageService takes paths, we might need to write to temp first or check if it supports bytes/streams
// Simulating via temp file for now as StorageService.store takes a Path
try {
Path tempFile = Files.createTempFile("print-calc-upload", ".tmp");
Files.write(tempFile, content);
storageService.store(tempFile, Paths.get(relativePath));
Files.delete(tempFile);
} catch (IOException e) {
throw new RuntimeException("Failed to save file " + relativePath, e);
}
}
private String getExtension(String filename) {
if (filename == null) return "stl";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
}
return "stl";
}
}

View File

@@ -16,6 +16,7 @@ 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.math.BigDecimal;
@Service @Service
public class ProfileManager { public class ProfileManager {
@@ -59,6 +60,18 @@ public class ProfileManager {
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
String resolvedName = profileAliases.getOrDefault(machineName, machineName);
if (nozzleDiameter == null) return resolvedName;
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) { 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);

View File

@@ -0,0 +1,68 @@
package com.printcalculator.service;
import com.printcalculator.entity.Order;
import net.codecrete.qrbill.generator.Bill;
import net.codecrete.qrbill.generator.GraphicsFormat;
import net.codecrete.qrbill.generator.QRBill;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class QrBillService {
public byte[] generateQrBillSvg(Order order) {
Bill bill = createBillFromOrder(order);
return QRBill.generate(bill);
}
public Bill createBillFromOrder(Order order) {
Bill bill = new Bill();
// Creditor (Merchant)
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
bill.setCreditor(createAddress(
"Küng, Joe",
"Via G. Pioda 29a",
"6710",
"Biasca",
"CH"
));
// Debtor (Customer)
String debtorName;
if ("BUSINESS".equals(order.getBillingCustomerType())) {
debtorName = order.getBillingCompanyName();
} else {
debtorName = order.getBillingFirstName() + " " + order.getBillingLastName();
}
bill.setDebtor(createAddress(
debtorName,
order.getBillingAddressLine1(), // Assuming simple address for now. Splitting might be needed if street/house number are separate
order.getBillingZip(),
order.getBillingCity(),
order.getBillingCountryCode()
));
// Amount
bill.setAmount(order.getTotalChf());
bill.setCurrency("CHF");
// Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
bill.setUnstructuredMessage("Order " + order.getId());
return bill;
}
private net.codecrete.qrbill.generator.Address createAddress(String name, String street, String zip, String city, String country) {
net.codecrete.qrbill.generator.Address address = new net.codecrete.qrbill.generator.Address();
address.setName(name);
address.setStreet(street);
address.setPostalCode(zip);
address.setTown(city);
address.setCountryCode(country);
return address;
}
}

View File

@@ -76,11 +76,21 @@ public class QuoteCalculator {
// --- CALCULATIONS --- // --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg // Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); // 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());
}
BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered // Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).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 machineCost = calculateMachineCost(policy, totalHours);
// Energy Cost: (watts / 1000) * hours * costPerKwh // Energy Cost: (watts / 1000) * hours * costPerKwh

View File

@@ -0,0 +1,79 @@
package com.printcalculator.service;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.QuoteSessionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.stream.Stream;
@Service
public class SessionCleanupService {
private static final Logger logger = LoggerFactory.getLogger(SessionCleanupService.class);
private final QuoteSessionRepository sessionRepository;
public SessionCleanupService(QuoteSessionRepository sessionRepository) {
this.sessionRepository = sessionRepository;
}
// Run every day at 3 AM
@Scheduled(cron = "0 0 3 * * ?")
@Transactional
public void cleanupOldSessions() {
logger.info("Starting session cleanup job...");
OffsetDateTime cutoff = OffsetDateTime.now().minusDays(15);
List<QuoteSession> oldSessions = sessionRepository.findByCreatedAtBefore(cutoff);
int deletedCount = 0;
for (QuoteSession session : oldSessions) {
// We only delete sessions that are NOT ordered?
// The user request was "delete old ones".
// Safest is to check status if we had one.
// QuoteSession entity has 'status' field.
// Let's assume we delete 'PENDING' or similar, but maybe we just delete all old inputs?
// "rimangono in memoria... cancella quelle vecchie di 7 giorni".
// Implementation plan said: status != 'ORDERED'.
// User specified statuses: ACTIVE, EXPIRED, CONVERTED.
// We should NOT delete sessions that have been converted to an order.
if ("CONVERTED".equals(session.getStatus())) {
continue;
}
try {
// Delete ACTIVE or EXPIRED sessions older than 7 days
deleteSessionFiles(session.getId().toString());
sessionRepository.delete(session);
deletedCount++;
} catch (Exception e) {
logger.error("Failed to cleanup session {}", session.getId(), e);
}
}
logger.info("Session cleanup job finished. Deleted {} sessions.", deletedCount);
}
private void deleteSessionFiles(String sessionId) {
Path sessionDir = Paths.get("storage_quotes", sessionId);
if (Files.exists(sessionDir)) {
try (Stream<Path> walk = Files.walk(sessionDir)) {
walk.sorted(java.util.Comparator.reverseOrder())
.map(Path::toFile)
.forEach(java.io.File::delete);
} catch (IOException e) {
logger.error("Failed to delete directory: {}", sessionDir, e);
}
}
}
}

View File

@@ -13,8 +13,10 @@ import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream;
@Service @Service
public class SlicerService { public class SlicerService {
@@ -39,21 +41,15 @@ public class SlicerService {
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName, public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException { Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine"); ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament"); ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process"); ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
// Apply Overrides if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
if (machineOverrides != null) { if (processOverrides != null) processOverrides.forEach(processProfile::put);
machineOverrides.forEach(machineProfile::put);
}
if (processOverrides != null) {
processOverrides.forEach(processProfile::put);
}
// 2. Create Temp Dir
Path tempDir = Files.createTempDirectory("slicer_job_"); Path tempDir = Files.createTempDirectory("slicer_job_");
try { try {
File mFile = tempDir.resolve("machine.json").toFile(); File mFile = tempDir.resolve("machine.json").toFile();
File fFile = tempDir.resolve("filament.json").toFile(); File fFile = tempDir.resolve("filament.json").toFile();
@@ -63,84 +59,61 @@ public class SlicerService {
mapper.writeValue(fFile, filamentProfile); mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile); mapper.writeValue(pFile, processProfile);
// 3. Build Command
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add(slicerPath); command.add(slicerPath);
// Load machine settings
command.add("--load-settings"); command.add("--load-settings");
command.add(mFile.getAbsolutePath()); command.add(mFile.getAbsolutePath());
// Load process settings
command.add("--load-settings"); command.add("--load-settings");
command.add(pFile.getAbsolutePath()); command.add(pFile.getAbsolutePath());
command.add("--load-filaments"); command.add("--load-filaments");
command.add(fFile.getAbsolutePath()); command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed"); command.add("--ensure-on-bed");
command.add("--arrange"); command.add("--arrange");
command.add("1"); // force arrange command.add("1");
command.add("--slice");
command.add("0"); // slice plate 0
command.add("--outputdir"); command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString()); command.add(tempDir.toAbsolutePath().toString());
// Need to handle Mac structure for console if needed?
// Usually the binary at Contents/MacOS/OrcaSlicer works fine as console app. command.add("--slice");
command.add("0");
command.add(inputStl.getAbsolutePath()); command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer: " + String.join(" ", command)); logger.info("Executing Slicer: " + String.join(" ", command));
// 4. Run Process runSlicerCommand(command, tempDir);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
Process process = pb.start(); try (Stream<Path> s = Files.list(tempDir)) {
boolean finished = process.waitFor(5, TimeUnit.MINUTES); Optional<Path> found = s.filter(p -> p.toString().endsWith(".gcode")).findFirst();
if (found.isPresent()) return gCodeParser.parse(found.get().toFile());
if (!finished) { else throw new IOException("No GCode found in " + tempDir);
process.destroy();
throw new IOException("Slicer timed out");
} }
if (process.exitValue() != 0) {
// Read stderr
String error = new String(process.getErrorStream().readAllBytes());
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
}
// 5. Find Output GCode
// Usually [basename].gcode or plate_1.gcode
String basename = inputStl.getName();
if (basename.toLowerCase().endsWith(".stl")) {
basename = basename.substring(0, basename.length() - 4);
}
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
if (!gcodeFile.exists()) {
// Try plate_1.gcode fallback
File alt = tempDir.resolve("plate_1.gcode").toFile();
if (alt.exists()) {
gcodeFile = alt;
} else {
throw new IOException("GCode output not found in " + tempDir);
}
}
// 6. Parse Results
return gCodeParser.parse(gcodeFile);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e); throw new IOException(e);
} finally { }
// Cleanup temp dir }
// In production we should delete, for debugging we might want to keep?
// Let's delete for now on success. protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
// recursiveDelete(tempDir); ProcessBuilder pb = new ProcessBuilder(command);
// Leaving it effectively "leaks" temp, but safer for persistent debugging? pb.directory(tempDir.toFile());
// Implementation detail: Use a utility to clean up.
Map<String, String> env = pb.environment();
env.put("HOME", "/tmp");
env.put("QT_QPA_PLATFORM", "offscreen");
Process process = pb.start();
if (!process.waitFor(5, TimeUnit.MINUTES)) {
process.destroy();
throw new IOException("Slicer timeout");
}
if (process.exitValue() != 0) {
String out = new String(process.getInputStream().readAllBytes());
String err = new String(process.getErrorStream().readAllBytes());
throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out);
} }
} }
} }

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

@@ -0,0 +1,14 @@
package com.printcalculator.service;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.io.IOException;
public interface StorageService {
void init();
void store(MultipartFile file, Path destination) throws IOException;
void store(Path source, Path destination) throws IOException;
void delete(Path path) throws IOException;
Resource loadAsResource(Path path) throws IOException;
}

View File

@@ -18,3 +18,9 @@ profiles.root=${PROFILES_DIR:profiles}
# File Upload Limits # File Upload Limits
spring.servlet.multipart.max-file-size=200MB spring.servlet.multipart.max-file-size=200MB
spring.servlet.multipart.max-request-size=200MB spring.servlet.multipart.max-request-size=200MB
# ClamAV Configuration
clamav.host=${CLAMAV_HOST:clamav}
clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="it" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<style>
@page { size: A4; margin: 18mm 15mm; }
body { font-family: sans-serif; font-size: 10.5pt; }
.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; }
th, td { padding: 6px; border-bottom: 1px solid #ccc; }
th { text-align: left; }
.totals { margin-top: 6mm; width: 40%; margin-left: auto; }
.totals td { border: none; }
.page-break { page-break-before: always; }
</style>
</head>
<body>
<div class="header">
<div>
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div th:text="${sellerEmail}">email@example.com</div>
</div>
<div>
<div><strong>Fattura</strong></div>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
</div>
</div>
<div class="addresses">
<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>
<tr>
<th>Descrizione</th>
<th style="text-align:right;">Qtà</th>
<th style="text-align:right;">Prezzo</th>
<th style="text-align:right;">Totale</th>
</tr>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr>
</tbody>
</table>
<table class="totals">
<tr>
<td>Subtotale</td>
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr>
<td><strong>Totale</strong></td>
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
</tr>
</table>
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div>
<div style="page-break-before: always;"></div>
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
</div>
</body>
</html>

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

View File

@@ -0,0 +1,23 @@
package com.printcalculator.config;
import com.printcalculator.service.ClamAVService;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import java.io.InputStream;
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public ClamAVService mockClamAVService() {
return new ClamAVService("localhost", 3310, true) {
@Override
public boolean scan(InputStream inputStream) {
return true; // Always clean for tests
}
};
}
}

View File

@@ -0,0 +1,176 @@
package com.printcalculator.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.dto.CustomerDto;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.util.FileSystemUtils;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import com.printcalculator.service.ClamAVService;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@AutoConfigureMockMvc
@org.springframework.test.context.TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL",
"spring.datasource.driverClassName=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
class OrderIntegrationTest {
@MockitoBean
private ClamAVService clamAVService;
@Autowired
private MockMvc mockMvc;
@Autowired
private QuoteSessionRepository sessionRepository;
@Autowired
private QuoteLineItemRepository lineItemRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ObjectMapper objectMapper;
private UUID sessionId;
private UUID lineItemId;
private final String TEST_FILENAME = "test_model.stl";
@BeforeEach
void setup() throws Exception {
// Mock ClamAV to always return true (safe)
when(clamAVService.scan(any())).thenReturn(true);
// 1. Create Quote Session
QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE");
session.setMaterialCode("PLA");
session.setPricingVersion("v1");
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
session.setSetupCostChf(BigDecimal.valueOf(5.00));
session.setSupportsEnabled(false);
session = sessionRepository.save(session);
this.sessionId = session.getId();
// 2. Create Dummy File on Disk (storage_quotes)
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
Files.createDirectories(sessionDir);
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
Files.writeString(filePath, "dummy content");
// 3. Create Quote Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setStatus("READY");
item.setOriginalFilename(TEST_FILENAME);
item.setStoredPath(filePath.toString());
item.setQuantity(2);
item.setPrintTimeSeconds(120);
item.setMaterialGrams(BigDecimal.valueOf(10.5));
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
item = lineItemRepository.save(item);
this.lineItemId = item.getId();
}
@AfterEach
void cleanup() throws Exception {
// Cleanup generated files
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
// Clean DB
orderRepository.deleteAll();
lineItemRepository.deleteAll();
sessionRepository.deleteAll();
}
@Test
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
// Prepare Request
CreateOrderRequest request = new CreateOrderRequest();
CustomerDto customer = new CustomerDto();
customer.setEmail("integration@test.com");
customer.setCustomerType("PRIVATE");
request.setCustomer(customer);
AddressDto billing = new AddressDto();
billing.setFirstName("John");
billing.setLastName("Doe");
billing.setAddressLine1("Street 1");
billing.setCity("City");
billing.setZip("1000");
billing.setCountryCode("CH");
request.setBillingAddress(billing);
request.setShippingSameAsBilling(true);
// Execute Request
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
// Verify Session Status
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
UUID orderId = updatedSession.getConvertedOrderId();
// Verify File Copy
Path orderStorageDir = Paths.get("storage_orders");
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
try (var stream = Files.walk(orderStorageDir)) {
boolean fileFound = stream
.filter(Files::isRegularFile)
.anyMatch(path -> {
try {
return Files.readString(path).equals("dummy content");
} catch (Exception e) {
return false;
}
});
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
}
}
}

View File

@@ -27,10 +27,10 @@ class GCodeParserTest {
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
// Assert // Assert
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3 assertEquals(3723L, stats.getPrintTimeSeconds()); // 3600 + 120 + 3
assertEquals("1h 2m 3s", stats.printTimeFormatted()); assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
assertEquals(10.5, stats.filamentWeightGrams(), 0.001); assertEquals(10.5, stats.getFilamentWeightGrams(), 0.001);
assertEquals(3000.0, stats.filamentLengthMm(), 0.001); assertEquals(3000.0, stats.getFilamentLengthMm(), 0.001);
tempFile.delete(); tempFile.delete();
} }
@@ -49,8 +49,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30 assertEquals(750L, stats.getPrintTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.filamentWeightGrams(), 0.001); assertEquals(5.0, stats.getFilamentWeightGrams(), 0.001);
tempFile.delete(); tempFile.delete();
} }
@@ -69,8 +69,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds()); assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("1h 2m 3s", stats.printTimeFormatted()); assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }
@@ -87,8 +87,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds()); assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("01:02:03", stats.printTimeFormatted()); assertEquals("01:02:03", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }
@@ -105,8 +105,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(321L, stats.printTimeSeconds()); assertEquals(321L, stats.getPrintTimeSeconds());
assertEquals("5m 21s", stats.printTimeFormatted()); assertEquals("5m 21s", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }

View File

@@ -0,0 +1,123 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.PrintStats;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
class SlicerServiceTest {
@Mock
private ProfileManager profileManager;
@Mock
private GCodeParser gCodeParser;
private ObjectMapper mapper = new ObjectMapper();
private SlicerService slicerService;
@TempDir
Path tempDir;
// Captured execution details
private List<String> lastCommand;
private Path lastTempDir;
@BeforeEach
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
// Subclass to override runSlicerCommand
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
@Override
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
lastCommand = command;
lastTempDir = tempDir;
// Don't run actual process.
// Simulate GCode output creation for the parser to find?
// Or just let it fail at parser step since we only care about JSON generation here?
// For a full test, we should create a dummy GCode file.
File stl = new File(command.get(command.size() - 1));
String basename = stl.getName().replace(".stl", "");
Files.createFile(tempDir.resolve(basename + ".gcode"));
}
};
// Mock Profile Responses
ObjectNode emptyNode = mapper.createObjectNode();
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
// Mock Parser
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
}
@Test
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
assertNotNull(lastTempDir);
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
}
@Test
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", "0.12");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertTrue(processJson.has("layer_height"));
assertEquals("0.12", processJson.get("layer_height").asText());
}
@Test
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("sparse_infill_density", "25%");
processOverrides.put("enable_support", "1");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertEquals("25%", processJson.get("sparse_infill_density").asText());
assertEquals("1", processJson.get("enable_support").asText());
}
}

395
db.sql
View File

@@ -12,12 +12,12 @@ create table printer_machine
fleet_weight numeric(6, 3) not null default 1.000, fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true, is_active boolean not null default true,
slicer_machine_profile varchar(255),
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );
create view printer_fleet_current as create view printer_fleet_current as
select 1 as fleet_id, select case
case
when sum(fleet_weight) = 0 then null when sum(fleet_weight) = 0 then null
else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer
end as weighted_average_power_watts, end as weighted_average_power_watts,
@@ -156,6 +156,23 @@ begin;
set timezone = 'Europe/Zurich'; set timezone = 'Europe/Zurich';
-- =========================================================
-- 0) (Solo se non esiste) tabella infill_pattern + seed
-- =========================================================
-- Se la tabella esiste già, commenta questo blocco.
create table if not exists infill_pattern
(
infill_pattern_id bigserial primary key,
pattern_code text not null unique, -- es: grid, gyroid
display_name text not null,
is_active boolean not null default true
);
insert into infill_pattern (pattern_code, display_name, is_active)
values ('grid', 'Grid', true),
('gyroid', 'Gyroid', true)
on conflict (pattern_code) do update
set display_name = excluded.display_name,
is_active = excluded.is_active; is_active = excluded.is_active;
@@ -163,8 +180,7 @@ set timezone = 'Europe/Zurich';
-- 1) Pricing policy (valori ESATTI da Excel) -- 1) Pricing policy (valori ESATTI da Excel)
-- Valid from: 2026-01-01, valid_to: NULL -- Valid from: 2026-01-01, valid_to: NULL
-- ========================================================= -- =========================================================
insert into pricing_policy ( insert into pricing_policy (policy_name,
policy_name,
valid_from, valid_from,
valid_to, valid_to,
electricity_cost_chf_per_kwh, electricity_cost_chf_per_kwh,
@@ -172,9 +188,8 @@ insert into pricing_policy (
fixed_job_fee_chf, fixed_job_fee_chf,
nozzle_change_base_fee_chf, nozzle_change_base_fee_chf,
cad_cost_chf_per_hour, cad_cost_chf_per_hour,
is_active is_active)
) values ( values ('Excel Tariffe 2026-01-01',
'Excel Tariffe 2026-01-01',
'2026-01-01 00:00:00+01'::timestamptz, '2026-01-01 00:00:00+01'::timestamptz,
null, null,
0.156, -- Costo elettricità CHF/kWh (Excel) 0.156, -- Costo elettricità CHF/kWh (Excel)
@@ -182,26 +197,20 @@ insert into pricing_policy (
1.00, -- Costo fisso macchina CHF (Excel) 1.00, -- Costo fisso macchina CHF (Excel)
0.00, -- Base cambio ugello: non specificato -> 0 0.00, -- Base cambio ugello: non specificato -> 0
25.00, -- Tariffa CAD CHF/h (Excel) 25.00, -- Tariffa CAD CHF/h (Excel)
true true)
)
on conflict do nothing; on conflict do nothing;
-- scaglioni tariffa stampa (Excel) -- scaglioni tariffa stampa (Excel)
insert into pricing_policy_machine_hour_tier ( insert into pricing_policy_machine_hour_tier (pricing_policy_id,
pricing_policy_id,
tier_start_hours, tier_start_hours,
tier_end_hours, tier_end_hours,
machine_cost_chf_per_hour machine_cost_chf_per_hour)
) select p.pricing_policy_id,
select
p.pricing_policy_id,
tiers.tier_start_hours, tiers.tier_start_hours,
tiers.tier_end_hours, tiers.tier_end_hours,
tiers.machine_cost_chf_per_hour tiers.machine_cost_chf_per_hour
from pricing_policy p from pricing_policy p
cross join ( cross join (values (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 010 h
values
(0.00::numeric, 10.00::numeric, 2.00::numeric), -- 010 h
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 h (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 h
(20.00::numeric, null::numeric, 0.50::numeric) -- >20 h (20.00::numeric, null::numeric, 0.50::numeric) -- >20 h
) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour) ) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour)
@@ -212,26 +221,22 @@ on conflict do nothing;
-- ========================================================= -- =========================================================
-- 2) Stampante: BambuLab A1 -- 2) Stampante: BambuLab A1
-- ========================================================= -- =========================================================
insert into printer_machine ( insert into printer_machine (printer_display_name,
printer_display_name,
build_volume_x_mm, build_volume_x_mm,
build_volume_y_mm, build_volume_y_mm,
build_volume_z_mm, build_volume_z_mm,
power_watts, power_watts,
fleet_weight, fleet_weight,
is_active is_active)
) values ( values ('BambuLab A1',
'BambuLab A1',
256, 256,
256, 256,
256, 256,
150, -- hai detto "150, 140": qui ho messo 150 150, -- hai detto "150, 140": qui ho messo 150
1.000, 1.000,
true true)
)
on conflict (printer_display_name) do update on conflict (printer_display_name) do update
set set build_volume_x_mm = excluded.build_volume_x_mm,
build_volume_x_mm = excluded.build_volume_x_mm,
build_volume_y_mm = excluded.build_volume_y_mm, build_volume_y_mm = excluded.build_volume_y_mm,
build_volume_z_mm = excluded.build_volume_z_mm, build_volume_z_mm = excluded.build_volume_z_mm,
power_watts = excluded.power_watts, power_watts = excluded.power_watts,
@@ -242,21 +247,18 @@ on conflict (printer_display_name) do update
-- ========================================================= -- =========================================================
-- 3) Material types (da Excel) - per ora niente technical -- 3) Material types (da Excel) - per ora niente technical
-- ========================================================= -- =========================================================
insert into filament_material_type ( insert into filament_material_type (material_code,
material_code,
is_flexible, is_flexible,
is_technical, is_technical,
technical_type_label technical_type_label)
) values values ('PLA', false, false, null),
('PLA', false, false, null),
('PETG', false, false, null), ('PETG', false, false, null),
('TPU', true, false, null), ('TPU', true, false, null),
('ABS', false, false, null), ('ABS', false, false, null),
('Nylon', false, false, null), ('Nylon', false, false, null),
('Carbon PLA', false, false, null) ('Carbon PLA', false, false, null)
on conflict (material_code) do update on conflict (material_code) do update
set set is_flexible = excluded.is_flexible,
is_flexible = excluded.is_flexible,
is_technical = excluded.is_technical, is_technical = excluded.is_technical,
technical_type_label = excluded.technical_type_label; technical_type_label = excluded.technical_type_label;
@@ -268,13 +270,11 @@ on conflict (material_code) do update
-- ========================================================= -- =========================================================
-- helper: ID PLA -- helper: ID PLA
with pla as ( with pla as (select filament_material_type_id
select filament_material_type_id
from filament_material_type from filament_material_type
where material_code = 'PLA' where material_code = 'PLA')
) insert
insert into filament_variant ( into filament_variant (filament_material_type_id,
filament_material_type_id,
variant_display_name, variant_display_name,
color_name, color_name,
is_matte, is_matte,
@@ -282,10 +282,8 @@ insert into filament_variant (
cost_chf_per_kg, cost_chf_per_kg,
stock_spools, stock_spools,
spool_net_kg, spool_net_kg,
is_active is_active)
) select pla.filament_material_type_id,
select
pla.filament_material_type_id,
v.variant_display_name, v.variant_display_name,
v.color_name, v.color_name,
v.is_matte, v.is_matte,
@@ -295,20 +293,17 @@ select
1.000, 1.000,
true true
from pla from pla
cross join ( cross join (values ('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
values
('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
('PLA Nero', 'Nero', false, false, 3.000::numeric), ('PLA Nero', 'Nero', false, false, 3.000::numeric),
('PLA Blu', 'Blu', false, false, 1.000::numeric), ('PLA Blu', 'Blu', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', false, false, 1.000::numeric), ('PLA Arancione', 'Arancione', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', false, false, 1.000::numeric), ('PLA Grigio', 'Grigio', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric), ('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric), ('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric),
('PLA Viola', 'Viola', false, false, 1.000::numeric) ('PLA Viola', 'Viola', false, false,
) as v(variant_display_name, color_name, is_matte, is_special, stock_spools) 1.000::numeric)) as v(variant_display_name, color_name, is_matte, is_special, stock_spools)
on conflict (filament_material_type_id, variant_display_name) do update on conflict (filament_material_type_id, variant_display_name) do update
set set color_name = excluded.color_name,
color_name = excluded.color_name,
is_matte = excluded.is_matte, is_matte = excluded.is_matte,
is_special = excluded.is_special, is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg, cost_chf_per_kg = excluded.cost_chf_per_kg,
@@ -321,17 +316,14 @@ on conflict (filament_material_type_id, variant_display_name) do update
-- 5) Ugelli -- 5) Ugelli
-- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF -- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF
-- ========================================================= -- =========================================================
insert into nozzle_option ( insert into nozzle_option (nozzle_diameter_mm,
nozzle_diameter_mm,
owned_quantity, owned_quantity,
extra_nozzle_change_fee_chf, extra_nozzle_change_fee_chf,
is_active is_active)
) values values (0.40, 1, 0.00, true),
(0.40, 1, 0.00, true),
(0.60, 1, 50.00, true) (0.60, 1, 50.00, true)
on conflict (nozzle_diameter_mm) do update on conflict (nozzle_diameter_mm) do update
set set owned_quantity = excluded.owned_quantity,
owned_quantity = excluded.owned_quantity,
extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf, extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
is_active = excluded.is_active; is_active = excluded.is_active;
@@ -339,28 +331,297 @@ on conflict (nozzle_diameter_mm) do update
-- ========================================================= -- =========================================================
-- 6) Layer heights (opzioni) -- 6) Layer heights (opzioni)
-- ========================================================= -- =========================================================
insert into layer_height_option ( insert into layer_height_option (layer_height_mm,
layer_height_mm,
time_multiplier, time_multiplier,
is_active is_active)
) values values (0.080, 1.000, true),
(0.080, 1.000, true),
(0.120, 1.000, true), (0.120, 1.000, true),
(0.160, 1.000, true), (0.160, 1.000, true),
(0.200, 1.000, true), (0.200, 1.000, true),
(0.240, 1.000, true), (0.240, 1.000, true),
(0.280, 1.000, true) (0.280, 1.000, true)
on conflict (layer_height_mm) do update on conflict (layer_height_mm) do update
set set time_multiplier = excluded.time_multiplier,
time_multiplier = excluded.time_multiplier,
is_active = excluded.is_active; is_active = excluded.is_active;
commit; commit;
-- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10) -- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10)
update layer_height_option update layer_height_option
set time_multiplier = 0.1 set time_multiplier = 0.1
where layer_height_mm = 0.080; where layer_height_mm = 0.080;
-- =========================
-- CUSTOMERS (minimo indispensabile)
-- =========================
CREATE TABLE IF NOT EXISTS customers
(
customer_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_type text NOT NULL CHECK (customer_type IN ('PRIVATE', 'COMPANY')),
email text NOT NULL,
phone text,
-- per PRIVATE
first_name text,
last_name text,
-- per COMPANY
company_name text,
contact_person text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_customers_email
ON customers (lower(email));
-- =========================
-- QUOTE SESSIONS (carrello preventivo)
-- =========================
CREATE TABLE IF NOT EXISTS quote_sessions
(
quote_session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
status text NOT NULL CHECK (status IN ('ACTIVE', 'EXPIRED', 'CONVERTED')),
pricing_version text NOT NULL,
-- Parametri "globali" (dalla tua UI avanzata)
material_code text NOT NULL, -- es: PLA, PETG...
nozzle_diameter_mm numeric(5, 2), -- es: 0.40
layer_height_mm numeric(6, 3), -- es: 0.20
infill_pattern text, -- es: grid
infill_percent integer CHECK (infill_percent BETWEEN 0 AND 100),
supports_enabled boolean NOT NULL DEFAULT false,
notes text,
setup_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
converted_order_id uuid
);
CREATE INDEX IF NOT EXISTS ix_quote_sessions_status
ON quote_sessions (status);
CREATE INDEX IF NOT EXISTS ix_quote_sessions_expires_at
ON quote_sessions (expires_at);
-- =========================
-- QUOTE LINE ITEMS (1 file = 1 riga)
-- =========================
CREATE TABLE IF NOT EXISTS quote_line_items
(
quote_line_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
quote_session_id uuid NOT NULL REFERENCES quote_sessions (quote_session_id) ON DELETE CASCADE,
status text NOT NULL CHECK (status IN ('CALCULATING', 'READY', 'FAILED')),
original_filename text NOT NULL,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
color_code text, -- es: white/black o codice interno
-- Output slicing / calcolo
bounding_box_x_mm numeric(10, 3),
bounding_box_y_mm numeric(10, 3),
bounding_box_z_mm numeric(10, 3),
print_time_seconds integer CHECK (print_time_seconds >= 0),
material_grams numeric(12, 2) CHECK (material_grams >= 0),
unit_price_chf numeric(12, 2) CHECK (unit_price_chf >= 0),
pricing_breakdown jsonb, -- opzionale: costi dettagliati senza creare tabelle
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_quote_line_items_session
ON quote_line_items (quote_session_id);
-- Vista utile per totale quote
CREATE OR REPLACE VIEW quote_session_totals AS
SELECT qs.quote_session_id,
qs.setup_cost_chf +
COALESCE(SUM(qli.unit_price_chf * qli.quantity), 0.00) AS total_chf
FROM quote_sessions qs
LEFT JOIN quote_line_items qli
ON qli.quote_session_id = qs.quote_session_id
AND qli.status = 'READY'
GROUP BY qs.quote_session_id;
-- =========================
-- ORDERS
-- =========================
CREATE TABLE IF NOT EXISTS orders
(
order_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_quote_session_id uuid REFERENCES quote_sessions (quote_session_id),
status text NOT NULL CHECK (status IN (
'PENDING_PAYMENT', 'PAID', 'IN_PRODUCTION',
'SHIPPED', 'COMPLETED', 'CANCELLED'
)),
customer_id uuid REFERENCES customers (customer_id),
customer_email text NOT NULL,
customer_phone text,
-- Snapshot indirizzo/fatturazione (evita tabella addresses e mantiene storico)
billing_customer_type text NOT NULL CHECK (billing_customer_type IN ('PRIVATE', 'COMPANY')),
billing_first_name text,
billing_last_name text,
billing_company_name text,
billing_contact_person text,
billing_address_line1 text NOT NULL,
billing_address_line2 text,
billing_zip text NOT NULL,
billing_city text NOT NULL,
billing_country_code char(2) NOT NULL DEFAULT 'CH',
shipping_same_as_billing boolean NOT NULL DEFAULT true,
shipping_first_name text,
shipping_last_name text,
shipping_company_name text,
shipping_contact_person text,
shipping_address_line1 text,
shipping_address_line2 text,
shipping_zip text,
shipping_city text,
shipping_country_code char(2),
currency char(3) NOT NULL DEFAULT 'CHF',
setup_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
shipping_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
discount_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
subtotal_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
total_chf numeric(12, 2) NOT NULL DEFAULT 0.00,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
paid_at timestamptz
);
CREATE INDEX IF NOT EXISTS ix_orders_status
ON orders (status);
CREATE INDEX IF NOT EXISTS ix_orders_customer_email
ON orders (lower(customer_email));
-- =========================
-- ORDER ITEMS (1 file 3D = 1 riga, file salvato su disco)
-- =========================
CREATE TABLE IF NOT EXISTS order_items
(
order_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
original_filename text NOT NULL,
stored_relative_path text NOT NULL, -- es: orders/<orderId>/3d-files/<orderItemId>/<uuid>.stl
stored_filename text NOT NULL, -- es: <uuid>.stl
file_size_bytes bigint CHECK (file_size_bytes >= 0),
mime_type text,
sha256_hex text, -- opzionale, utile anche per dedup interno
material_code text NOT NULL,
color_code text,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
-- Snapshot output
print_time_seconds integer CHECK (print_time_seconds >= 0),
material_grams numeric(12, 2) CHECK (material_grams >= 0),
unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0),
line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_order_items_order
ON order_items (order_id);
-- =========================
-- PAYMENTS (supporta più tentativi / metodi)
-- =========================
CREATE TABLE IF NOT EXISTS payments
(
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
-- riferimento pagamento (molto utile per QR bill / riconciliazione)
payment_reference text,
provider_transaction_id text,
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(),
received_at timestamptz
);
CREATE INDEX IF NOT EXISTS ix_payments_order
ON payments (order_id);
CREATE INDEX IF NOT EXISTS ix_payments_reference
ON payments (payment_reference);
-- =========================
-- CUSTOM QUOTE REQUESTS (preventivo personalizzato, form che hai mostrato)
-- =========================
CREATE TABLE IF NOT EXISTS custom_quote_requests
(
request_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
request_type text NOT NULL, -- es: "PREVENTIVO_PERSONALIZZATO" o come preferisci
customer_type text NOT NULL CHECK (customer_type IN ('PRIVATE', 'COMPANY')),
email text NOT NULL,
phone text,
-- PRIVATE
name text,
-- COMPANY
company_name text,
contact_person text,
message text NOT NULL,
status text NOT NULL CHECK (status IN ('NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_status
ON custom_quote_requests (status);
CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_email
ON custom_quote_requests (lower(email));
-- Allegati della richiesta (max 15 come UI)
CREATE TABLE IF NOT EXISTS custom_quote_request_attachments
(
attachment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
request_id uuid NOT NULL REFERENCES custom_quote_requests (request_id) ON DELETE CASCADE,
original_filename text NOT NULL,
stored_relative_path text NOT NULL, -- es: quote-requests/<requestId>/attachments/<attachmentId>/<uuid>.stl
stored_filename text NOT NULL,
file_size_bytes bigint CHECK (file_size_bytes >= 0),
mime_type text,
sha256_hex text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request
ON custom_quote_request_attachments (request_id);

View File

@@ -15,9 +15,16 @@ services:
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=host.docker.internal
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
restart: always restart: always
volumes: volumes:
- backend_profiles_${ENV}:/app/profiles - backend_profiles_${ENV}:/app/profiles
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
extra_hosts:
- "host.docker.internal:host-gateway"
frontend: frontend:

View File

@@ -13,15 +13,22 @@ services:
- DB_USERNAME=printcalc - DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret - DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local - SPRING_PROFILES_ACTIVE=local
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
- PRINTER_POWER_WATTS=150
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=clamav
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
depends_on: depends_on:
- db - db
- clamav
restart: unless-stopped
clamav:
platform: linux/amd64
image: clamav/clamav:latest
container_name: print-calculator-clamav
ports:
- "3310:3310"
restart: unless-stopped restart: unless-stopped
frontend: frontend:

View File

@@ -25,9 +25,21 @@ export const routes: Routes = [
path: 'contact', path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
}, },
{
path: 'checkout',
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent)
},
{
path: 'payment/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{ {
path: '', path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
},
{
path: '**',
redirectTo: ''
} }
] ]
} }

View File

@@ -0,0 +1,40 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
export interface QuoteRequestDto {
requestType: string;
customerType: string;
email: string;
phone?: string;
name?: string;
companyName?: string;
contactPerson?: string;
message: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteRequestService {
private http = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/api/custom-quote-requests`;
createRequest(request: QuoteRequestDto, files: File[]): Observable<any> {
const formData = new FormData();
// Append Request DTO as JSON Blob
const requestBlob = new Blob([JSON.stringify(request)], {
type: 'application/json'
});
formData.append('request', requestBlob);
// Append Files
files.forEach(file => {
formData.append('files', file);
});
return this.http.post(this.apiUrl, formData);
}
}

View File

@@ -2,8 +2,10 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p> <p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error()) { @if (error() === 'VIRUS_DETECTED') {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert> <app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert>
} @else if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
} }
</div> </div>
@@ -11,14 +13,6 @@
<div class="container hero"> <div class="container hero">
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state> <app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
</div> </div>
} @else if (step() === 'details' && result()) {
<div class="container">
<app-user-details
[quote]="result()!"
(submitOrder)="onSubmitOrder($event)"
(cancel)="onCancelDetails()">
</app-user-details>
</div>
} @else { } @else {
<div class="container content-grid"> <div class="container content-grid">
<!-- Left Column: Input --> <!-- Left Column: Input -->
@@ -27,12 +21,12 @@
<div class="mode-selector"> <div class="mode-selector">
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
(click)="mode.set('easy')"> (click)="setMode('easy')">
{{ 'CALC.MODE_EASY' | translate }} {{ 'CALC.MODE_EASY' | translate }}
</div> </div>
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"> (click)="setMode('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }} {{ 'CALC.MODE_ADVANCED' | translate }}
</div> </div>
</div> </div>
@@ -43,6 +37,7 @@
[loading]="loading()" [loading]="loading()"
[uploadProgress]="uploadProgress()" [uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
(itemRemoved)="onItemRemoved($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
</div> </div>
@@ -50,7 +45,8 @@
<!-- Right Column: Result or Info --> <!-- Right Column: Result or Info -->
<div class="col-result" #resultCol> <div class="col-result" #resultCol>
@if (loading()) { @if (loading() && !result()) {
<!-- Initial Loading State (before first result) -->
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <div class="loader-content">
<div class="spinner"></div> <div class="spinner"></div>
@@ -59,11 +55,20 @@
</div> </div>
</app-card> </app-card>
} @else if (result()) { } @else if (result()) {
<!-- Result State (Active or Finished) -->
@if (loading()) {
<!-- Small loader indicator when refining results -->
<div class="analyzing-bar">
<div class="spinner-small"></div>
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
</div>
}
<app-quote-result <app-quote-result
[result]="result()!" [result]="result()!"
(consult)="onConsult()" (consult)="onConsult()"
(proceed)="onProceed()" (proceed)="onProceed()"
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)" (itemChange)="onItemChange($event)"
></app-quote-result> ></app-quote-result>
} @else { } @else {
<app-card> <app-card>

View File

@@ -1,20 +1,21 @@
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { forkJoin } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service'; import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
import { UserDetailsComponent } from './components/user-details/user-details.component';
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component'; import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
@Component({ @Component({
selector: 'app-calculator-page', selector: 'app-calculator-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent], imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent],
templateUrl: './calculator-page.component.html', templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss' styleUrl: './calculator-page.component.scss'
}) })
@@ -25,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
loading = signal(false); loading = signal(false);
uploadProgress = signal(0); uploadProgress = signal(0);
result = signal<QuoteResult | null>(null); result = signal<QuoteResult | null>(null);
error = signal<boolean>(false); error = signal<string | null>(null);
orderSuccess = signal(false); orderSuccess = signal(false);
@@ -44,6 +45,99 @@ export class CalculatorPageComponent implements OnInit {
this.mode.set(data['mode']); this.mode.set(data['mode']);
} }
}); });
this.route.queryParams.subscribe(params => {
const sessionId = params['session'];
if (sessionId && sessionId !== this.result()?.sessionId) {
this.loadSession(sessionId);
}
});
}
loadSession(sessionId: string) {
this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({
next: (data) => {
// 1. Map to Result
const result = this.estimator.mapSessionToQuoteResult(data);
this.result.set(result);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
// If we have custom settings, maybe Advanced?
// For now, let's stick to current mode or infer from URL if possible.
// Actually, we can check if settings deviate from Easy defaults.
// But let's leave it as is or default to Advanced if not sure.
// data.session.materialCode etc.
// 3. Download Files & Restore Form
this.restoreFilesAndSettings(data.session, data.items);
},
error: (err) => {
console.error('Failed to load session', err);
this.error.set('Failed to load session');
this.loading.set(false);
}
});
}
restoreFilesAndSettings(session: any, items: any[]) {
if (!items || items.length === 0) {
this.loading.set(false);
return;
}
// Download all files
const downloads = items.map(item =>
this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => {
return {
blob,
fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally.
// UploadForm.setFiles takes File[].
// We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list.
};
})
)
);
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
const colors = items.map(i => i.colorCode || 'Black');
if (this.uploadForm) {
this.uploadForm.setFiles(files, colors);
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with correct colors now.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
// Assuming index matches.
// Need to be careful if items order changed, but usually ID sort or insert order.
if (item.colorCode) {
this.uploadForm.updateItemColor(index, item.colorCode);
}
if (item.quantity) {
this.uploadForm.updateItemQuantityAtIndex(index, item.quantity);
}
});
this.uploadForm.updateItemIdsByIndex(items.map(i => i.id));
}
});
}
this.loading.set(false);
},
error: (err: any) => {
console.error('Failed to download files', err);
this.loading.set(false);
// Still show result? Yes.
}
});
} }
onCalculate(req: QuoteRequest) { onCalculate(req: QuoteRequest) {
@@ -51,7 +145,7 @@ export class CalculatorPageComponent implements OnInit {
this.currentRequest = req; this.currentRequest = req;
this.loading.set(true); this.loading.set(true);
this.uploadProgress.set(0); this.uploadProgress.set(0);
this.error.set(false); this.error.set(null);
this.result.set(null); this.result.set(null);
this.orderSuccess.set(false); this.orderSuccess.set(false);
@@ -67,28 +161,116 @@ export class CalculatorPageComponent implements OnInit {
if (typeof event === 'number') { if (typeof event === 'number') {
this.uploadProgress.set(event); this.uploadProgress.set(event);
} else { } else {
// It's the result // It's the result (partial or final)
this.result.set(event as QuoteResult); const res = event as QuoteResult;
this.loading.set(false); this.result.set(res);
this.uploadProgress.set(100);
// Show result immediately if not already showing
if (this.step() !== 'quote') {
this.step.set('quote'); this.step.set('quote');
} }
// Sync IDs back to upload form for future updates
if (this.uploadForm) {
this.uploadForm.updateItemIdsByIndex(res.items.map(i => i.id));
}
// Update URL with session ID without reloading
if (res.sessionId) {
// Check if we need to update URL to avoid redundant navigations
const currentSession = this.route.snapshot.queryParamMap.get('session');
if (currentSession !== res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
}
}
}, },
error: () => { complete: () => {
this.error.set(true); this.loading.set(false);
this.uploadProgress.set(100);
},
error: (err) => {
if (typeof err === 'string') {
this.error.set(err);
} else {
this.error.set('GENERIC');
}
this.loading.set(false); this.loading.set(false);
} }
}); });
} }
onProceed() { onProceed() {
this.step.set('details'); const res = this.result();
if (res && res.sessionId) {
this.router.navigate(['/checkout'], { queryParams: { session: res.sessionId } });
} else {
console.error('No session ID found in quote result');
// Fallback or error handling
}
} }
onCancelDetails() { onCancelDetails() {
this.step.set('quote'); this.step.set('quote');
} }
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
}
// 2. Update backend session if ID exists
if (event.id) {
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
next: (res) => console.log('Line item updated', res),
error: (err) => console.error('Failed to update line item', err)
});
}
}
onItemRemoved(event: {index: number, id?: string}) {
// 1. Update local result if exists to keep UI in sync
const currentRes = this.result();
if (currentRes) {
const updatedItems = [...currentRes.items];
updatedItems.splice(event.index, 1);
// Recalculate totals locally for immediate feedback
let totalTime = 0;
let totalWeight = 0;
let itemsPrice = 0;
updatedItems.forEach(i => {
totalTime += i.unitTime * i.quantity;
totalWeight += i.unitWeight * i.quantity;
itemsPrice += i.unitPrice * i.quantity;
});
this.result.set({
...currentRes,
items: updatedItems,
totalPrice: Math.round((itemsPrice + currentRes.setupCost) * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight)
});
}
// 2. Delete from backend if ID exists
if (event.id && currentRes?.sessionId) {
this.estimator.deleteLineItem(currentRes.sessionId, event.id).subscribe({
next: () => console.log('Line item deleted from backend'),
error: (err) => console.error('Failed to delete line item', err)
});
}
}
onSubmitOrder(orderData: any) { onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData); console.log('Order Submitted:', orderData);
this.orderSuccess.set(true); this.orderSuccess.set(true);
@@ -134,4 +316,12 @@ export class CalculatorPageComponent implements OnInit {
this.router.navigate(['/contact']); this.router.navigate(['/contact']);
} }
setMode(mode: 'easy' | 'advanced') {
const path = mode === 'easy' ? 'basic' : 'advanced';
this.router.navigate(['../', path], {
relativeTo: this.route,
queryParamsHandling: 'merge'
});
}
} }

View File

@@ -35,16 +35,26 @@
<!-- Detailed Items List (NOW ON BOTTOM) --> <!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list"> <div class="items-list">
@for (item of items(); track item.fileName; let i = $index) { @for (item of items(); track item; let i = $index) {
<div class="item-row"> <div class="item-row" [class.has-error]="item.error">
<div class="item-info"> <div class="item-info">
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
@if (item.error) {
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
} @else if (item.status === 'pending') {
<span class="file-details pending">
<div class="spinner-mini"></div> Analisi...
</span>
} @else {
<span class="file-details"> <span class="file-details">
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g {{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span> </span>
}
</div> </div>
<div class="item-controls"> <div class="item-controls">
@if (!item.error) {
<div class="qty-control"> <div class="qty-control">
<label>Qtà:</label> <label>Qtà:</label>
<input <input
@@ -57,6 +67,13 @@
<div class="item-price"> <div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }} {{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div> </div>
} @else if (item.status === 'pending') {
<div class="item-price pending">
<div class="spinner-mini"></div>
</div>
} @else {
<div class="item-price error">-</div>
}
</div> </div>
</div> </div>
} }

View File

@@ -21,6 +21,11 @@
background: var(--color-neutral-50); background: var(--color-neutral-50);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
&.has-error {
border-color: #ef4444;
background: #fef2f2;
}
} }
.item-info { .item-info {
@@ -31,7 +36,21 @@
} }
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); } .file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.color-badge {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid var(--color-border);
display: inline-block;
}
.file-error { font-size: 0.8rem; color: #ef4444; font-weight: 500; }
.item-controls { .item-controls {
display: flex; display: flex;

View File

@@ -6,6 +6,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component'; import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service'; import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
@Component({ @Component({
selector: 'app-quote-result', selector: 'app-quote-result',
@@ -18,11 +19,13 @@ export class QuoteResultComponent {
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
itemChange = output<{fileName: string, quantity: number}>(); itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
getColorHex = getColorHex;
constructor() { constructor() {
effect(() => { effect(() => {
// Initialize local items when result inputs change // Initialize local items when result inputs change
@@ -42,8 +45,10 @@ export class QuoteResultComponent {
}); });
this.itemChange.emit({ this.itemChange.emit({
id: this.items()[index].id,
fileName: this.items()[index].fileName, fileName: this.items()[index].fileName,
quantity: qty quantity: qty,
index: index
}); });
} }
@@ -56,9 +61,11 @@ export class QuoteResultComponent {
let weight = 0; let weight = 0;
currentItems.forEach(i => { currentItems.forEach(i => {
if (i.status === 'done' && !i.error) {
price += i.unitPrice * i.quantity; price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity; time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity; weight += i.unitWeight * i.quantity;
}
}); });
const hours = Math.floor(time / 3600); const hours = Math.floor(time / 3600);

View File

@@ -25,7 +25,7 @@
<!-- New File List with Details --> <!-- New File List with Details -->
@if (items().length > 0) { @if (items().length > 0) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) { @for (item of items(); track item; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)"> <div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header"> <div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span> <span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>

View File

@@ -12,6 +12,7 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
import { getColorHex } from '../../../../core/constants/colors.const'; import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem { interface FormItem {
id?: string;
file: File; file: File;
quantity: number; quantity: number;
color: string; color: string;
@@ -29,6 +30,7 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false); loading = input<boolean>(false);
uploadProgress = input<number>(0); uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>(); submitRequest = output<QuoteRequest>();
itemRemoved = output<{index: number, id?: string}>();
private estimator = inject(QuoteEstimatorService); private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -75,7 +77,7 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required], nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'], infillPattern: ['grid'],
supportEnabled: [false] supportEnabled: [true]
}); });
// Listen to material changes to update variants // Listen to material changes to update variants
@@ -112,7 +114,9 @@ export class UploadFormComponent implements OnInit {
private setDefaults() { private setDefaults() {
// Set Defaults if available // Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) { if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value); // Prefer PLA Basic, otherwise first available
const pla = this.materials().find(m => m.value === 'pla_basic');
this.form.get('material')?.setValue(pla ? pla.value : this.materials()[0].value);
} }
if (this.qualities().length > 0 && !this.form.get('quality')?.value) { if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first // Try to find 'standard' or use first
@@ -176,6 +180,37 @@ export class UploadFormComponent implements OnInit {
}); });
} }
updateItemQuantityAtIndex(index: number, quantity: number) {
this.items.update(current => {
const updated = [...current];
if (updated[index]) {
updated[index] = { ...updated[index], quantity };
}
return updated;
});
}
updateItemIds(itemsWithIds: { fileName: string, id: string }[]) {
this.items.update(current => {
return current.map(item => {
const match = itemsWithIds.find(i => i.fileName === item.file.name && !i.id); // This matching is weak
// Better: matching should be based on index if we trust order
return item;
});
});
}
updateItemIdsByIndex(ids: (string | undefined)[]) {
this.items.update(current => {
return current.map((item, i) => {
if (ids[i]) {
return { ...item, id: ids[i] };
}
return item;
});
});
}
selectFile(file: File) { selectFile(file: File) {
if (this.selectedFile() === file) { if (this.selectedFile() === file) {
// toggle off? no, keep active // toggle off? no, keep active
@@ -206,11 +241,7 @@ export class UploadFormComponent implements OnInit {
let val = parseInt(input.value, 10); let val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) val = 1; if (isNaN(val) || val < 1) val = 1;
this.items.update(current => { this.updateItemQuantityAtIndex(index, val);
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
} }
updateItemColor(index: number, newColor: string) { updateItemColor(index: number, newColor: string) {
@@ -222,6 +253,7 @@ export class UploadFormComponent implements OnInit {
} }
removeItem(index: number) { removeItem(index: number) {
const itemToRemove = this.items()[index];
this.items.update(current => { this.items.update(current => {
const updated = [...current]; const updated = [...current];
const removed = updated.splice(index, 1)[0]; const removed = updated.splice(index, 1)[0];
@@ -230,6 +262,60 @@ export class UploadFormComponent implements OnInit {
} }
return updated; return updated;
}); });
this.itemRemoved.emit({ index, id: itemToRemove.id });
}
setFiles(files: File[], colors?: string[]) {
const validItems: FormItem[] = [];
files.forEach((file, i) => {
const color = (colors && colors[i]) ? colors[i] : 'Black';
validItems.push({ file, quantity: 1, color: color });
});
if (validItems.length > 0) {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
patchSettings(settings: any) {
if (!settings) return;
// settings object matches keys in our form?
// Session has: materialCode, etc. derived from QuoteSession entity properties
// We need to map them if names differ.
const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode;
// Heuristic for Quality if not explicitly stored as "draft/standard/high"
// But we stored it in session creation?
// QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill.
// So we might need to deduce it or just set Custom/Advanced.
// But for Easy mode, we want to show "Standard" etc.
// Actually, let's look at what we have in QuoteSession.
// layerHeightMm, infillPercent, etc.
// If we are in Easy mode, we might just set the "quality" dropdown to match approx?
// Or if we stored "quality" in notes or separate field? We didn't.
// Let's try to reverse map or defaults.
if (settings.layerHeightMm) {
if (settings.layerHeightMm >= 0.28) patch.quality = 'draft';
else if (settings.layerHeightMm <= 0.12) patch.quality = 'high';
else patch.quality = 'standard';
patch.layerHeight = settings.layerHeightMm;
}
if (settings.nozzleDiameterMm) patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined) patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes;
this.form.patchValue(patch);
} }
onSubmit() { onSubmit() {

View File

@@ -18,6 +18,7 @@ export interface QuoteRequest {
} }
export interface QuoteItem { export interface QuoteItem {
id?: string;
fileName: string; fileName: string;
unitPrice: number; unitPrice: number;
unitTime: number; // seconds unitTime: number; // seconds
@@ -25,9 +26,12 @@ export interface QuoteItem {
quantity: number; quantity: number;
material?: string; material?: string;
color?: string; color?: string;
error?: string;
status: 'pending' | 'done' | 'error';
} }
export interface QuoteResult { export interface QuoteResult {
sessionId?: string;
items: QuoteItem[]; items: QuoteItem[];
setupCost: number; setupCost: number;
currency: string; currency: string;
@@ -120,6 +124,53 @@ export class QuoteEstimatorService {
); );
} }
// NEW METHODS for Order Flow
getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers });
}
updateLineItem(lineItemId: string, changes: any): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
}
deleteLineItem(sessionId: string, lineItemId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.delete(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}`, { headers });
}
createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
}
getOrder(orderId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
headers,
responseType: 'blob'
});
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request); console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) { if (request.items.length === 0) {
@@ -128,201 +179,159 @@ export class QuoteEstimatorService {
} }
return new Observable(observer => { return new Observable(observer => {
const totalItems = request.items.length; // 1. Create Session first
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
// machine param removed - backend uses default active
// Map material? Or trust frontend to send correct code?
// Since we fetch options now, we should send the code directly.
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
// If frontend sends 'PLA', mapMaterial returns 'pla_basic'.
// We should check if request.material is already a code from options.
// For now, let's assume request.material IS the code if it matches our new options,
// or fallback to mapper if it's old legacy string.
// Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes.
// For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'.
// Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA').
// Backend expects 'pla_basic' or just 'PLA'?
// QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'.
// So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code.
// Backend OptionsController returns type.getMaterialCode() which is 'PLA'.
// So we still need mapping to slicer profile names.
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
// Send color for both modes if present, defaulting to Black
formData.append('material_color', item.color || 'Black');
if (request.mode === 'advanced') {
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
if (request.supportEnabled) formData.append('support_enabled', 'true');
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
}
const headers: any = {}; const headers: any = {};
// @ts-ignore // @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post<BackendResponse | BackendQuoteResult>(`${environment.apiUrl}/api/quote`, formData, { this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
headers, next: (sessionRes) => {
reportProgress: true, const sessionId = sessionRes.id;
observe: 'events' const sessionSetupCost = sessionRes.setupCostChf || 0;
}).pipe(
map(event => ({ item, event, index })),
catchError(err => of({ item, error: err, index }))
);
});
// Subscribe to all // Initialize items in pending state
uploads.forEach((obs) => { const currentItems: QuoteItem[] = request.items.map(item => ({
obs.subscribe({ fileName: item.file.name,
next: (wrapper: any) => { unitPrice: 0,
const idx = wrapper.index; unitTime: 0,
unitWeight: 0,
quantity: item.quantity,
status: 'pending',
color: item.color || 'White' // Default color for UI
}));
if (wrapper.error) { // Emit initial state
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; const initialResult: QuoteResult = {
} sessionId: sessionId,
items: [...currentItems],
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: 0, // Will be calculated dynamically
totalTimeHours: 0,
totalTimeMinutes: 0,
totalWeight: 0,
notes: request.notes
};
observer.next(initialResult);
const event = wrapper.event; // 2. Upload files to this session
if (event && event.type === HttpEventType.UploadProgress) { const totalItems = request.items.length;
if (event.total) { const allProgress: number[] = new Array(totalItems).fill(0);
const percent = Math.round((100 * event.loaded) / event.total); let completedRequests = 0;
allProgress[idx] = percent;
// Emit average progress const emitUpdate = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg); observer.next(avg);
}
} else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
// It's done (either response or error caught above)
if (!finalResponses[idx]) { // only if not already set by error
allProgress[idx] = 100;
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
} else {
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
}
completedRequests++;
}
if (completedRequests === totalItems) { // Helper to calculate totals for current items
// All done let grandTotal = 0;
observer.next(100);
// Calculate Results
let setupCost = 10;
let setupCostFromBackend: number | null = null;
let currencyFromBackend: string | null = null;
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
setupCost += 2;
}
const items: QuoteItem[] = [];
finalResponses.forEach((res, idx) => {
if (!res) return;
const originalItem = request.items[idx];
const normalized = this.normalizeResponse(res);
if (!normalized.success) return;
if (normalized.currency && currencyFromBackend == null) {
currencyFromBackend = normalized.currency;
}
if (normalized.setupCost != null && setupCostFromBackend == null) {
setupCostFromBackend = normalized.setupCost;
}
items.push({
fileName: res.fileName,
unitPrice: normalized.unitPrice,
unitTime: normalized.unitTime,
unitWeight: normalized.unitWeight,
quantity: res.originalQty, // Use the requested quantity
material: request.material,
color: originalItem.color || 'Default'
});
});
if (items.length === 0) {
observer.error('All calculations failed.');
return;
}
// Initial Aggregation
const useBackendSetup = setupCostFromBackend != null;
let grandTotal = useBackendSetup ? 0 : setupCost;
let totalTime = 0; let totalTime = 0;
let totalWeight = 0; let totalWeight = 0;
let validCount = 0;
items.forEach(item => { currentItems.forEach(item => {
if (item.status === 'done') {
grandTotal += item.unitPrice * item.quantity; grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity; totalTime += item.unitTime * item.quantity;
totalWeight += item.unitWeight * item.quantity; totalWeight += item.unitWeight * item.quantity;
validCount++;
}
}); });
const totalHours = Math.floor(totalTime / 3600); if (validCount > 0) {
const totalMinutes = Math.ceil((totalTime % 3600) / 60); grandTotal += sessionSetupCost;
}
const result: QuoteResult = { const result: QuoteResult = {
items, sessionId: sessionId,
setupCost: useBackendSetup ? setupCostFromBackend! : setupCost, items: [...currentItems], // Create copy to trigger change detection
currency: currencyFromBackend || 'CHF', setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100, totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: totalHours, totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: totalMinutes, totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight), totalWeight: Math.ceil(totalWeight),
notes: request.notes notes: request.notes
}; };
observer.next(result); observer.next(result);
if (completedRequests === totalItems) {
observer.complete(); observer.complete();
} }
};
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
const settings = {
complexityMode: request.mode.toUpperCase(),
material: this.mapMaterial(request.material),
quality: request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: request.mode === 'advanced' ? request.layerHeight : null,
infillDensity: request.mode === 'advanced' ? request.infillDensity : null,
infillPattern: request.mode === 'advanced' ? request.infillPattern : null,
nozzleDiameter: request.mode === 'advanced' ? request.nozzleDiameter : null
};
const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' });
formData.append('settings', settingsBlob);
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
emitUpdate();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
const resBody = event.body as any;
// Update item in list
currentItems[index] = {
id: resBody.id,
fileName: resBody.originalFilename, // use returned filename
unitPrice: resBody.unitPriceChf || 0,
unitTime: resBody.printTimeSeconds || 0,
unitWeight: resBody.materialGrams || 0,
quantity: item.quantity, // Keep original quantity
material: request.material,
color: item.color || 'White',
status: 'done'
};
completedRequests++;
emitUpdate();
} }
}, },
error: (err) => { error: (err) => {
console.error('Error in request subscription', err); console.error('Item upload failed', err);
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
currentItems[index] = {
...currentItems[index],
status: 'error',
error: errorMsg
};
allProgress[index] = 100; // Mark as done despite error
completedRequests++; completedRequests++;
if (completedRequests === totalItems) { emitUpdate();
observer.error('Requests failed');
}
} }
}); });
}); });
},
error: (err) => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session');
}
});
}); });
}
private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } {
if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') {
return {
success: true,
unitPrice: res.totalPrice,
unitTime: res.stats.printTimeSeconds,
unitWeight: res.stats.filamentWeightGrams,
setupCost: res.setupCost,
currency: res.currency
};
}
if (res && res.success && res.data) {
return {
success: true,
unitPrice: res.data.cost.total,
unitTime: res.data.print_time_seconds,
unitWeight: res.data.material_grams,
currency: 'CHF'
};
}
return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 };
} }
private mapMaterial(mat: string): string { private mapMaterial(mat: string): string {
@@ -333,13 +342,6 @@ export class QuoteEstimatorService {
return 'pla_basic'; return 'pla_basic';
} }
private mapQuality(qual: string): string {
const q = qual.toLowerCase();
if (q.includes('draft')) return 'draft';
if (q.includes('high')) return 'extra_fine';
return 'standard';
}
// Consultation Data Transfer // Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null); private pendingConsultation = signal<{files: File[], message: string} | null>(null);
@@ -352,4 +354,46 @@ export class QuoteEstimatorService {
this.pendingConsultation.set(null); // Clear after reading this.pendingConsultation.set(null); // Clear after reading
return data; return data;
} }
// Session File Retrieval
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, {
headers,
responseType: 'blob'
});
}
mapSessionToQuoteResult(sessionData: any): QuoteResult {
const session = sessionData.session;
const items = sessionData.items || [];
const totalTime = items.reduce((acc: number, item: any) => acc + (item.printTimeSeconds || 0) * item.quantity, 0);
const totalWeight = items.reduce((acc: number, item: any) => acc + (item.materialGrams || 0) * item.quantity, 0);
return {
sessionId: session.id,
items: items.map((item: any) => ({
id: item.id,
fileName: item.originalFilename,
unitPrice: item.unitPriceChf,
unitTime: item.printTimeSeconds,
unitWeight: item.materialGrams,
quantity: item.quantity,
material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode.
// But line items might have different colors.
color: item.colorCode,
status: 'done'
})),
setupCost: session.setupCostChf,
currency: 'CHF', // Fixed for now
totalPrice: sessionData.grandTotalChf,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: session.notes
};
}
} }

View File

@@ -0,0 +1,154 @@
<div class="container hero">
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="checkout-layout">
<!-- LEFT COLUMN: Form -->
<div class="checkout-form-section">
<!-- Error Message -->
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card -->
<app-card class="mb-6">
<div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div>
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
{{ 'CHECKOUT.PRIVATE' | translate }}
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CHECKOUT.COMPANY' | translate }}
</div>
</div>
<div formGroupName="billingAddress">
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input>
<div class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div>
</div>
<div *ngIf="!isCompany" class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div>
</div>
</app-card>
<!-- Billing Address Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
</div>
</div>
</app-card>
<!-- Shipping Option -->
<div class="shipping-option">
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label>
</div>
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div>
</app-card>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button>
</div>
</form>
</div>
<!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
</div>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>Qty: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ 9.0 | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ (session.grandTotalChf + 9.0) | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,306 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
text-align: center;
h1 {
font-size: 2.5rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.125rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.checkout-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--space-8);
align-items: start;
margin-bottom: var(--space-12);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
gap: var(--space-8);
}
}
.card-header-simple {
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
}
.form-row {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
flex-direction: row;
& > * { flex: 1; }
}
&.no-margin {
margin-bottom: 0;
}
&.three-cols {
display: grid;
grid-template-columns: 1.5fr 2fr 1fr;
gap: var(--space-4);
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
app-input {
width: 100%;
}
}
/* User Type Selector - Matching Contact Form Style */
.user-type-selector {
display: flex;
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-4);
gap: 4px;
width: 100%;
max-width: 400px;
}
.type-option {
flex: 1;
text-align: center;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.selected {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.company-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-left: var(--space-4);
border-left: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
.shipping-option {
margin: var(--space-6) 0;
padding: var(--space-4);
background: var(--color-neutral-100);
border-radius: var(--radius-md);
}
/* Custom Checkbox */
.checkbox-container {
display: flex;
align-items: center;
position: relative;
padding-left: 36px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
user-select: none;
color: var(--color-text);
input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
&:checked ~ .checkmark {
background-color: var(--color-brand);
border-color: var(--color-brand);
&:after {
display: block;
}
}
}
.checkmark {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: 24px;
width: 24px;
background-color: var(--color-bg-card);
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
transition: all 0.2s;
&:after {
content: "";
position: absolute;
display: none;
left: 7px;
top: 3px;
width: 6px;
height: 12px;
border: solid #000;
border-width: 0 2.5px 2.5px 0;
transform: rotate(45deg);
}
}
&:hover input ~ .checkmark {
border-color: var(--color-brand);
}
}
.checkout-summary-section {
position: relative;
}
.sticky-card {
position: sticky;
top: var(--space-6);
}
.summary-items {
margin-bottom: var(--space-6);
max-height: 450px;
overflow-y: auto;
padding-right: var(--space-2);
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border);
&:first-child { padding-top: 0; }
&:last-child { border-bottom: none; }
.item-details {
flex: 1;
.item-name {
display: block;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: var(--space-1);
word-break: break-all;
color: var(--color-text);
}
.item-specs {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.85rem;
color: var(--color-text-muted);
.color-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--color-border);
}
}
.item-specs-sub {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 2px;
}
}
.item-price {
font-weight: 600;
margin-left: var(--space-3);
white-space: nowrap;
color: var(--color-text);
}
}
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-6);
border-radius: var(--radius-md);
margin-top: var(--space-4);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 2px solid var(--color-border);
}
}
.actions {
margin-top: var(--space-8);
app-button {
width: 100%;
}
}
.error-message {
color: var(--color-error);
background: #fef2f2;
padding: var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-6);
border: 1px solid #fee2e2;
font-weight: 500;
}
.mb-6 { margin-bottom: var(--space-6); }

View File

@@ -0,0 +1,191 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
@Component({
selector: 'app-checkout',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppButtonComponent,
AppCardComponent
],
templateUrl: './checkout.component.html',
styleUrl: './checkout.component.scss'
})
export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder);
private quoteService = inject(QuoteEstimatorService);
private router = inject(Router);
private route = inject(ActivatedRoute);
checkoutForm: FormGroup;
sessionId: string | null = null;
loading = false;
error: string | null = null;
isSubmitting = signal(false); // Add signal for submit state
quoteSession = signal<any>(null); // Add signal for session details
constructor() {
this.checkoutForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
phone: ['', Validators.required],
customerType: ['PRIVATE', Validators.required], // Default to PRIVATE
shippingSameAsBilling: [true],
billingAddress: this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
companyName: [''],
addressLine1: ['', Validators.required],
addressLine2: [''],
zip: ['', Validators.required],
city: ['', Validators.required],
countryCode: ['CH', Validators.required]
}),
shippingAddress: this.fb.group({
firstName: [''],
lastName: [''],
companyName: [''],
addressLine1: [''],
addressLine2: [''],
zip: [''],
city: [''],
countryCode: ['CH']
})
});
}
get isCompany(): boolean {
return this.checkoutForm.get('customerType')?.value === 'BUSINESS';
}
setCustomerType(isCompany: boolean) {
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
this.checkoutForm.patchValue({ customerType: type });
// Update validators based on type
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
const companyControl = billingGroup.get('companyName');
if (isCompany) {
companyControl?.setValidators([Validators.required]);
} else {
companyControl?.clearValidators();
}
companyControl?.updateValueAndValidity();
}
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.sessionId = params['session'];
if (!this.sessionId) {
this.error = 'No active session found. Please start a new quote.';
this.router.navigate(['/']); // Redirect if no session
return;
}
this.loadSessionDetails();
});
// Toggle shipping validation based on checkbox
this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => {
const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup;
if (isSame) {
shippingGroup.disable();
} else {
shippingGroup.enable();
}
});
// Initial state
this.checkoutForm.get('shippingAddress')?.disable();
}
loadSessionDetails() {
if (!this.sessionId) return; // Ensure sessionId is present before fetching
this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => {
this.quoteSession.set(session);
console.log('Loaded session:', session);
},
error: (err) => {
console.error('Failed to load session', err);
this.error = 'Failed to load session details. Please try again.';
}
});
}
onSubmit() {
if (this.checkoutForm.invalid) {
return;
}
this.isSubmitting.set(true);
this.error = null; // Clear previous errors
const formVal = this.checkoutForm.getRawValue(); // Use getRawValue to include disabled fields
// Construct request object matching backend DTO based on original form structure
const orderRequest = {
customer: {
email: formVal.email,
phone: formVal.phone,
customerType: formVal.customerType,
// Assuming firstName, lastName, companyName for customer come from billingAddress if not explicitly in contact group
firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName
},
billingAddress: {
firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName,
addressLine1: formVal.billingAddress.addressLine1,
addressLine2: formVal.billingAddress.addressLine2,
zip: formVal.billingAddress.zip,
city: formVal.billingAddress.city,
countryCode: formVal.billingAddress.countryCode
},
shippingAddress: formVal.shippingSameAsBilling ? null : {
firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName,
addressLine1: formVal.shippingAddress.addressLine1,
addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip,
city: formVal.shippingAddress.city,
countryCode: formVal.shippingAddress.countryCode
},
shippingSameAsBilling: formVal.shippingSameAsBilling
};
if (!this.sessionId) {
this.error = 'No active session found. Cannot create order.';
this.isSubmitting.set(false);
return;
}
this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({
next: (order) => {
console.log('Order created', order);
this.router.navigate(['/payment', order.id]);
},
error: (err) => {
console.error('Order creation failed', err);
this.isSubmitting.set(false);
this.error = 'Failed to create order. Please try again.';
}
});
}
}

View File

@@ -40,7 +40,7 @@
<div class="form-group"> <div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label> <label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea> <textarea formControlName="message" class="form-control" rows="10"></textarea>
</div> </div>
<!-- File Upload Section --> <!-- File Upload Section -->

View File

@@ -1,10 +1,11 @@
import { Component, signal, effect } from '@angular/core'; import { Component, signal, effect, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
import { QuoteRequestService } from '../../../../core/services/quote-request.service';
interface FilePreview { interface FilePreview {
file: File; file: File;
@@ -37,6 +38,8 @@ export class ContactFormComponent {
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
]; ];
private quoteRequestService = inject(QuoteRequestService);
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private translate: TranslateService, private translate: TranslateService,
@@ -156,13 +159,34 @@ export class ContactFormComponent {
onSubmit() { onSubmit() {
if (this.form.valid) { if (this.form.valid) {
const formData = { const formVal = this.form.value;
...this.form.value, const isCompany = formVal.isCompany;
files: this.files().map(f => f.file)
};
console.log('Form Submit:', formData);
const requestDto: any = {
requestType: formVal.requestType,
customerType: isCompany ? 'BUSINESS' : 'PRIVATE',
email: formVal.email,
phone: formVal.phone,
message: formVal.message
};
if (isCompany) {
requestDto.companyName = formVal.companyName;
requestDto.contactPerson = formVal.referencePerson;
} else {
requestDto.name = formVal.name;
}
this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({
next: () => {
this.sent.set(true); this.sent.set(true);
},
error: (err) => {
console.error('Submission failed', err);
alert('Error submitting request. Please try again.');
}
});
} else { } else {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
} }

View File

@@ -0,0 +1,113 @@
<div class="container hero">
<h1>{{ 'PAYMENT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="payment-layout" *ngIf="order() as o">
<div class="payment-main">
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
</div>
<div class="payment-selection">
<div class="methods-grid">
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')">
<span class="method-name">TWINT</span>
</div>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')">
<span class="method-name">QR Bill / Bank Transfer</span>
</div>
</div>
</div>
<!-- TWINT Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div>
<div class="qr-placeholder">
<div class="qr-box">
<span>QR CODE</span>
</div>
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
</div>
</div>
<!-- QR Bill Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
<div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
</div>
<div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
<div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
</app-button>
</div>
</div>
</div>
<div class="actions">
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
{{ 'PAYMENT.CONFIRM' | translate }}
</app-button>
</div>
</app-card>
</div>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
</div>
<div class="summary-totals">
<div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
<div *ngIf="loading()" class="loading-state">
<app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
</app-card>
</div>
<div *ngIf="error()" class="error-message">
<app-card>
<p>{{ error() }}</p>
</app-card>
</div>
</div>

View File

@@ -0,0 +1,195 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
text-align: center;
h1 {
font-size: 2.5rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.125rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.payment-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--space-8);
align-items: start;
margin-bottom: var(--space-12);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
gap: var(--space-8);
}
}
.card-header-simple {
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
h3 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.order-id {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: 2px;
}
}
.payment-selection {
margin-bottom: var(--space-6);
}
.methods-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.type-option {
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg-card);
text-align: center;
font-weight: 600;
color: var(--color-text-muted);
&:hover {
border-color: var(--color-brand);
color: var(--color-text);
}
&.selected {
border-color: var(--color-brand);
background-color: var(--color-neutral-100);
color: #000;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.payment-details {
background: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
border: 1px solid var(--color-border);
.details-header {
margin-bottom: var(--space-4);
h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.qr-box {
width: 180px;
height: 180px;
background-color: white;
border: 2px solid var(--color-neutral-900);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: var(--space-4);
border-radius: var(--radius-md);
}
.amount {
font-size: 1.25rem;
font-weight: 700;
margin-top: var(--space-2);
color: var(--color-text);
}
}
.bank-details {
p {
margin-bottom: var(--space-2);
font-size: 1rem;
color: var(--color-text);
}
}
.qr-bill-actions {
margin-top: var(--space-4);
}
.sticky-card {
position: sticky;
top: var(--space-6);
}
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-6);
border-radius: var(--radius-md);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 2px solid var(--color-border);
}
}
.actions {
margin-top: var(--space-8);
}
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.mb-6 { margin-bottom: var(--space-6); }
.error-message, .loading-state {
margin-top: var(--space-12);
text-align: center;
}

View File

@@ -0,0 +1,76 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-payment',
standalone: true,
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
templateUrl: './payment.component.html',
styleUrl: './payment.component.scss'
})
export class PaymentComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private quoteService = inject(QuoteEstimatorService);
orderId: string | null = null;
selectedPaymentMethod: 'twint' | 'bill' | null = null;
order = signal<any>(null);
loading = signal(true);
error = signal<string | null>(null);
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
if (this.orderId) {
this.loadOrder();
} else {
this.error.set('Order ID not found.');
this.loading.set(false);
}
}
loadOrder() {
if (!this.orderId) return;
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.order.set(order);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load order', err);
this.error.set('Failed to load order details.');
this.loading.set(false);
}
});
}
selectPayment(method: 'twint' | 'bill'): void {
this.selectedPaymentMethod = method;
}
downloadInvoice() {
if (!this.orderId) return;
this.quoteService.getOrderInvoice(this.orderId).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${this.orderId}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
},
error: (err) => console.error('Failed to download invoice', err)
});
}
completeOrder(): void {
// Simulate payment completion
alert('Payment Simulated! Order marked as PAID.');
this.router.navigate(['/']);
}
}

View File

@@ -1,6 +1,6 @@
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); } .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); } label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
.required-mark { color: var(--color-danger-500); margin-left: 2px; } .required-mark { color: var(--color-text); margin-left: 2px; }
.form-control { .form-control {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View File

@@ -40,7 +40,7 @@
"CTA_START": "Start Now", "CTA_START": "Start Now",
"BUSINESS": "Business", "BUSINESS": "Business",
"PRIVATE": "Private", "PRIVATE": "Private",
"MODE_EASY": "Quick", "MODE_EASY": "Easy Print",
"MODE_ADVANCED": "Advanced", "MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here", "UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB", "UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
@@ -61,6 +61,9 @@
"ORDER": "Order Now", "ORDER": "Order Now",
"CONSULT": "Request Consultation", "CONSULT": "Request Consultation",
"ERROR_GENERIC": "An error occurred while calculating the quote.", "ERROR_GENERIC": "An error occurred while calculating the quote.",
"ERROR_UPLOAD_FAILED": "File upload failed. Please try again.",
"ERROR_VIRUS_DETECTED": "File removed (virus detected)",
"ERROR_SLICING_FAILED": "Slicing error (complex geometry?)",
"NEW_QUOTE": "Calculate New Quote", "NEW_QUOTE": "Calculate New Quote",
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully", "ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.", "ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
@@ -148,5 +151,50 @@
"SUCCESS_TITLE": "Message Sent Successfully", "SUCCESS_TITLE": "Message Sent Successfully",
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.", "SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
"SEND_ANOTHER": "Send Another Message" "SEND_ANOTHER": "Send Another Message"
},
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
"CONTACT_INFO": "Contact Information",
"BILLING_ADDR": "Billing Address",
"SHIPPING_ADDR": "Shipping Address",
"SHIPPING_SAME": "Shipping address same as billing",
"ORDER_SUMMARY": "Order Summary",
"SUBTOTAL": "Subtotal",
"SETUP_FEE": "Setup Fee",
"SHIPPING": "Shipping",
"TOTAL": "Total",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"PRIVATE": "Private",
"COMPANY": "Company",
"FIRST_NAME": "First Name",
"LAST_NAME": "Last Name",
"EMAIL": "Email",
"PHONE": "Phone",
"COMPANY_NAME": "Company Name",
"ADDRESS_1": "Address Line 1",
"ADDRESS_2": "Address Line 2 (Optional)",
"ZIP": "ZIP Code",
"CITY": "City",
"COUNTRY": "Country"
},
"PAYMENT": {
"TITLE": "Payment",
"METHOD": "Payment Method",
"TWINT_TITLE": "Pay with TWINT",
"TWINT_DESC": "Scan the code with your TWINT app",
"BANK_TITLE": "Bank Transfer",
"BANK_OWNER": "Owner",
"BANK_IBAN": "IBAN",
"BANK_REF": "Reference",
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
"CONFIRM": "Confirm Order",
"SUMMARY_TITLE": "Order Summary",
"SUBTOTAL": "Subtotal",
"SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"LOADING": "Loading order details..."
} }
} }

View File

@@ -17,7 +17,7 @@
"CTA_START": "Inizia Ora", "CTA_START": "Inizia Ora",
"BUSINESS": "Aziende", "BUSINESS": "Aziende",
"PRIVATE": "Privati", "PRIVATE": "Privati",
"MODE_EASY": "Base", "MODE_EASY": "Stampa Facile",
"MODE_ADVANCED": "Avanzata", "MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui", "UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB", "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
@@ -40,6 +40,9 @@
"ORDER": "Ordina Ora", "ORDER": "Ordina Ora",
"CONSULT": "Richiedi Consulenza", "CONSULT": "Richiedi Consulenza",
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.", "ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
"ERROR_UPLOAD_FAILED": "Caricamento file fallito. Riprova.",
"ERROR_VIRUS_DETECTED": "File rimosso (virus rilevato)",
"ERROR_SLICING_FAILED": "Errore slicing (geometria complessa?)",
"NEW_QUOTE": "Calcola Nuovo Preventivo", "NEW_QUOTE": "Calcola Nuovo Preventivo",
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo", "ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.", "ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
@@ -127,5 +130,50 @@
"SUCCESS_TITLE": "Messaggio Inviato con Successo", "SUCCESS_TITLE": "Messaggio Inviato con Successo",
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.", "SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
"SEND_ANOTHER": "Invia un altro messaggio" "SEND_ANOTHER": "Invia un altro messaggio"
},
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
"CONTACT_INFO": "Informazioni di Contatto",
"BILLING_ADDR": "Indirizzo di Fatturazione",
"SHIPPING_ADDR": "Indirizzo di Spedizione",
"SHIPPING_SAME": "Indirizzo di spedizione uguale a quello di fatturazione",
"ORDER_SUMMARY": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SETUP_FEE": "Costo Setup",
"SHIPPING": "Spedizione",
"TOTAL": "Totale",
"PLACE_ORDER": "Conferma Ordine",
"PROCESSING": "Elaborazione...",
"PRIVATE": "Privato",
"COMPANY": "Azienda",
"FIRST_NAME": "Nome",
"LAST_NAME": "Cognome",
"EMAIL": "Email",
"PHONE": "Telefono",
"COMPANY_NAME": "Nome Azienda",
"ADDRESS_1": "Indirizzo riga 1",
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
"ZIP": "CAP",
"CITY": "Città",
"COUNTRY": "Paese"
},
"PAYMENT": {
"TITLE": "Pagamento",
"METHOD": "Metodo di Pagamento",
"TWINT_TITLE": "Paga con TWINT",
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
"BANK_TITLE": "Bonifico Bancario",
"BANK_OWNER": "Titolare",
"BANK_IBAN": "IBAN",
"BANK_REF": "Riferimento",
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
"CONFIRM": "Conferma Ordine",
"SUMMARY_TITLE": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SHIPPING": "Spedizione",
"SETUP_FEE": "Costo Setup",
"TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine..."
} }
} }