From 9cbd856ab6cf3c757bab0b6dfd8cd2b9ac76a2ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 15:26:58 +0100 Subject: [PATCH 01/72] feat(back-end): new db for custom quote requests --- GEMINI.md | 47 +- README.md | 93 ++- .../entity/CustomQuoteRequest.java | 149 +++++ .../entity/CustomQuoteRequestAttachment.java | 119 ++++ .../com/printcalculator/entity/Customer.java | 126 ++++ .../com/printcalculator/entity/Order.java | 413 +++++++++++++ .../com/printcalculator/entity/OrderItem.java | 198 +++++++ .../com/printcalculator/entity/Payment.java | 146 +++++ .../printcalculator/entity/QuoteLineItem.java | 203 +++++++ .../printcalculator/entity/QuoteSession.java | 176 ++++++ .../entity/QuoteSessionTotal.java | 29 + .../AbstractAuditableRepository.java | 9 + .../AbstractPersistableRepository.java | 9 + ...ustomQuoteRequestAttachmentRepository.java | 9 + .../CustomQuoteRequestRepository.java | 9 + .../repository/CustomerRepository.java | 9 + .../FilamentVariantStockKgRepository.java | 7 + .../repository/OrderItemRepository.java | 9 + .../repository/OrderRepository.java | 9 + .../repository/PaymentRepository.java | 9 + .../PrinterFleetCurrentRepository.java | 7 + .../repository/QuoteLineItemRepository.java | 9 + .../repository/QuoteSessionRepository.java | 9 + db.sql | 558 +++++++++++++----- 24 files changed, 2142 insertions(+), 219 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java create mode 100644 backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java create mode 100644 backend/src/main/java/com/printcalculator/entity/Customer.java create mode 100644 backend/src/main/java/com/printcalculator/entity/Order.java create mode 100644 backend/src/main/java/com/printcalculator/entity/OrderItem.java create mode 100644 backend/src/main/java/com/printcalculator/entity/Payment.java create mode 100644 backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java create mode 100644 backend/src/main/java/com/printcalculator/entity/QuoteSession.java create mode 100644 backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java create mode 100644 backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/CustomerRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/OrderRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/PaymentRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java create mode 100644 backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java diff --git a/GEMINI.md b/GEMINI.md index 997d781..6fc8544 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -4,39 +4,42 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e ## Project Overview **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**: -- **Backend**: Python (FastAPI), libreria `trimesh` per analisi geometrica. -- **Frontend**: Angular 19 (TypeScript). +- **Backend**: Java 21 (Spring Boot 3.4), PostgreSQL, Flyway. +- **Frontend**: Angular 19 (TypeScript), Angular Material, Three.js per visualizzazione 3D. ## Architecture ### Backend (`/backend`) -- **`main.py`**: Entrypoint dell'applicazione FastAPI. - - Definisce l'API `POST /calculate/stl`. - - Gestisce l'upload del file, invoca lo slicer e restituisce il preventivo. - - Configura CORS per permettere chiamate dal frontend. -- **`slicer.py`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello. - - Gestisce i profili di stampa (Macchina, Processo, Filamento). - - Crea configurazioni on-the-fly per supportare mesh di grandi dimensioni. -- **`calculator.py`**: Analizza il G-Code generato. - - `GCodeParser`: Estrae tempo di stampa e materiale usato dai metadati del G-Code. - - `QuoteCalculator`: Applica i costi (orari, energia, materiale) per generare il prezzo finale. +- **`BackendApplication.java`**: Entrypoint dell'applicazione Spring Boot. +- **`controller/`**: Espone le API REST per l'upload e il calcolo dei preventivi. +- **`service/SlicerService.java`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello. + - Gestisce i profili di stampa (Macchina, Processo, Filamento) caricati da file JSON. + - Crea configurazioni on-the-fly e invoca OrcaSlicer in modalità headless. +- **`service/GCodeParser.java`**: Analizza il G-Code generato per estrarre tempo di stampa e peso del materiale dai metadati del file. +- **`service/QuoteCalculator.java`**: Calcola il prezzo finale basandosi su politiche di prezzo salvate nel database. + - Gestisce costi macchina a scaglioni (tiered pricing). + - Calcola costi energetici basati sulla potenza della stampante e costo del kWh. + - Applica markup percentuali e fee fissi per job. ### Frontend (`/frontend`) -- Applicazione Angular standard. -- Usa Angular Material. -- Service per upload STL e visualizzazione preventivo. +- Applicazione Angular 19 con architettura modulare (core, features, shared). +- **Three.js**: Utilizzato per il rendering dei file STL caricati dall'utente. +- **Angular Material**: Per l'interfaccia utente. +- **ngx-translate**: Per il supporto multilingua. ## 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. -- **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`). +- **Real Slicing**: Il backend esegue un vero slicing usando OrcaSlicer. Questo garantisce stime di tempo e materiale estremamente precise. +- **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 -- Per eseguire il backend serve `uvicorn`. -- Il frontend richiede `npm install` al primo avvio. -- 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. +- **Backend**: Richiede JDK 21. Si avvia con `./gradlew bootRun`. +- **Database**: Richiede PostgreSQL. Le migrazioni sono gestite da Flyway. +- **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 - **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). diff --git a/README.md b/README.md index 14a9c95..f7a89c4 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,67 @@ # 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à -* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, non semplici approssimazioni geometriche. -* **Preventivazione Completa**: Calcola costo materiale, ammortamento macchina, energia e ricarico. -* **Configurabile**: Prezzi e parametri macchina modificabili via variabili d'ambiente. -* **Docker Ready**: Tutto containerizzato per un facile deployment. +* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, garantendo la massima precisione. +* **Preventivazione Database-Driven**: Calcolo basato su politiche di prezzo configurabili nel database (costo materiale, ammortamento macchina a scaglioni, energia e markup). +* **Visualizzazione 3D**: Anteprima del file STL caricato tramite Three.js. +* **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 -* Docker Desktop & Docker Compose installati. +* **Java 21** installato. +* **Node.js 22** e **npm** installati. +* **PostgreSQL** attivo. +* **OrcaSlicer** installato sul sistema. ## Avvio Rapido -1. Clona il repository. -2. Esegui lo script di avvio o docker-compose: - ```bash - docker-compose up --build - ``` - *Nota: La prima build impiegherà alcuni minuti per scaricare OrcaSlicer (~200MB) e compilare il Frontend.* +### 1. Database +Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. -3. Accedi all'applicazione: - * **Frontend**: [http://localhost](http://localhost) - * **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs) +### 2. Backend +Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. -## 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 cd backend -pip install -r requirements.txt -# Assicurati di avere OrcaSlicer installato e nel PATH o aggiorna SLICER_PATH in slicer.py -uvicorn main:app --reload +./gradlew bootRun ``` -**Frontend**: +### 3. Frontend ```bash cd frontend npm install 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. diff --git a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java new file mode 100644 index 0000000..a9f9a36 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java new file mode 100644 index 0000000..1c60d24 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java @@ -0,0 +1,119 @@ +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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/Customer.java b/backend/src/main/java/com/printcalculator/entity/Customer.java new file mode 100644 index 0000000..50b96a2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Customer.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java new file mode 100644 index 0000000..9ca1ea6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -0,0 +1,413 @@ +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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java new file mode 100644 index 0000000..2fa952d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/Payment.java b/backend/src/main/java/com/printcalculator/entity/Payment.java new file mode 100644 index 0000000..73b5a31 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Payment.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java new file mode 100644 index 0000000..ec526b3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -0,0 +1,203 @@ +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) + 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 pricingBreakdown; + + @Column(name = "error_message", length = Integer.MAX_VALUE) + private String errorMessage; + + @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 getPricingBreakdown() { + return pricingBreakdown; + } + + public void setPricingBreakdown(Map pricingBreakdown) { + this.pricingBreakdown = pricingBreakdown; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + 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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java new file mode 100644 index 0000000..3979b54 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java @@ -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; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java b/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java new file mode 100644 index 0000000..9a2fce1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java @@ -0,0 +1,29 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.hibernate.annotations.Immutable; + +import java.math.BigDecimal; +import java.util.UUID; + +@Entity +@Immutable +@Table(name = "quote_session_totals") +public class QuoteSessionTotal { + @Column(name = "quote_session_id") + private UUID quoteSessionId; + + @Column(name = "total_chf") + private BigDecimal totalChf; + + public UUID getQuoteSessionId() { + return quoteSessionId; + } + + public BigDecimal getTotalChf() { + return totalChf; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java b/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java new file mode 100644 index 0000000..8049dc9 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import org.springframework.data.jpa.domain.AbstractAuditable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +@NoRepositoryBean +public interface AbstractAuditableRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java b/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java new file mode 100644 index 0000000..d9e0bc4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import org.springframework.data.jpa.domain.AbstractPersistable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +@NoRepositoryBean +public interface AbstractPersistableRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java new file mode 100644 index 0000000..c256003 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java new file mode 100644 index 0000000..4b7fb71 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java new file mode 100644 index 0000000..f874743 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.Customer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface CustomerRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java new file mode 100644 index 0000000..934ca46 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java new file mode 100644 index 0000000..b43a305 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface OrderItemRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java new file mode 100644 index 0000000..e8351d7 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java b/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java new file mode 100644 index 0000000..1cd5fca --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java b/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java new file mode 100644 index 0000000..805e0d0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java @@ -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 { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java new file mode 100644 index 0000000..2f4d591 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.QuoteLineItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface QuoteLineItemRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java new file mode 100644 index 0000000..ba18585 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.QuoteSession; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface QuoteSessionRepository extends JpaRepository { +} \ No newline at end of file diff --git a/db.sql b/db.sql index f7b376b..eadd022 100644 --- a/db.sql +++ b/db.sql @@ -16,11 +16,10 @@ create table printer_machine ); create view printer_fleet_current as -select 1 as fleet_id, - case +select case when sum(fleet_weight) = 0 then null else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer - end as weighted_average_power_watts, + end as weighted_average_power_watts, max(build_volume_x_mm) as fleet_max_build_x_mm, max(build_volume_y_mm) as fleet_max_build_y_mm, max(build_volume_z_mm) as fleet_max_build_z_mm @@ -156,54 +155,63 @@ begin; set timezone = 'Europe/Zurich'; - is_active = excluded.is_active; +-- ========================================================= +-- 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; -- ========================================================= -- 1) Pricing policy (valori ESATTI da Excel) -- Valid from: 2026-01-01, valid_to: NULL -- ========================================================= -insert into pricing_policy ( - policy_name, - valid_from, - valid_to, - electricity_cost_chf_per_kwh, - markup_percent, - fixed_job_fee_chf, - nozzle_change_base_fee_chf, - cad_cost_chf_per_hour, - is_active -) values ( - 'Excel Tariffe 2026-01-01', - '2026-01-01 00:00:00+01'::timestamptz, - null, - 0.156, -- Costo elettricità CHF/kWh (Excel) - 0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo) - 1.00, -- Costo fisso macchina CHF (Excel) - 0.00, -- Base cambio ugello: non specificato -> 0 - 25.00, -- Tariffa CAD CHF/h (Excel) - true - ) +insert into pricing_policy (policy_name, + valid_from, + valid_to, + electricity_cost_chf_per_kwh, + markup_percent, + fixed_job_fee_chf, + nozzle_change_base_fee_chf, + cad_cost_chf_per_hour, + is_active) +values ('Excel Tariffe 2026-01-01', + '2026-01-01 00:00:00+01'::timestamptz, + null, + 0.156, -- Costo elettricità CHF/kWh (Excel) + 0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo) + 1.00, -- Costo fisso macchina CHF (Excel) + 0.00, -- Base cambio ugello: non specificato -> 0 + 25.00, -- Tariffa CAD CHF/h (Excel) + true) on conflict do nothing; -- scaglioni tariffa stampa (Excel) -insert into pricing_policy_machine_hour_tier ( - pricing_policy_id, - tier_start_hours, - tier_end_hours, - machine_cost_chf_per_hour -) -select - p.pricing_policy_id, - tiers.tier_start_hours, - tiers.tier_end_hours, - tiers.machine_cost_chf_per_hour +insert into pricing_policy_machine_hour_tier (pricing_policy_id, + tier_start_hours, + tier_end_hours, + machine_cost_chf_per_hour) +select p.pricing_policy_id, + tiers.tier_start_hours, + tiers.tier_end_hours, + tiers.machine_cost_chf_per_hour from pricing_policy p - cross join ( - values - (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 0–10 h - (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–20 h - (20.00::numeric, null::numeric, 0.50::numeric) -- >20 h + cross join (values (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 0–10 h + (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–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) where p.policy_name = 'Excel Tariffe 2026-01-01' on conflict do nothing; @@ -212,52 +220,45 @@ on conflict do nothing; -- ========================================================= -- 2) Stampante: BambuLab A1 -- ========================================================= -insert into printer_machine ( - printer_display_name, - build_volume_x_mm, - build_volume_y_mm, - build_volume_z_mm, - power_watts, - fleet_weight, - is_active -) values ( - 'BambuLab A1', - 256, - 256, - 256, - 150, -- hai detto "150, 140": qui ho messo 150 - 1.000, - true - ) +insert into printer_machine (printer_display_name, + build_volume_x_mm, + build_volume_y_mm, + build_volume_z_mm, + power_watts, + fleet_weight, + is_active) +values ('BambuLab A1', + 256, + 256, + 256, + 150, -- hai detto "150, 140": qui ho messo 150 + 1.000, + true) on conflict (printer_display_name) do update - set - build_volume_x_mm = excluded.build_volume_x_mm, + set build_volume_x_mm = excluded.build_volume_x_mm, build_volume_y_mm = excluded.build_volume_y_mm, build_volume_z_mm = excluded.build_volume_z_mm, - power_watts = excluded.power_watts, - fleet_weight = excluded.fleet_weight, - is_active = excluded.is_active; + power_watts = excluded.power_watts, + fleet_weight = excluded.fleet_weight, + is_active = excluded.is_active; -- ========================================================= -- 3) Material types (da Excel) - per ora niente technical -- ========================================================= -insert into filament_material_type ( - material_code, - is_flexible, - is_technical, - technical_type_label -) values - ('PLA', false, false, null), - ('PETG', false, false, null), - ('TPU', true, false, null), - ('ABS', false, false, null), - ('Nylon', false, false, null), - ('Carbon PLA', false, false, null) +insert into filament_material_type (material_code, + is_flexible, + is_technical, + technical_type_label) +values ('PLA', false, false, null), + ('PETG', false, false, null), + ('TPU', true, false, null), + ('ABS', false, false, null), + ('Nylon', false, false, null), + ('Carbon PLA', false, false, null) on conflict (material_code) do update - set - is_flexible = excluded.is_flexible, - is_technical = excluded.is_technical, + set is_flexible = excluded.is_flexible, + is_technical = excluded.is_technical, technical_type_label = excluded.technical_type_label; @@ -268,99 +269,358 @@ on conflict (material_code) do update -- ========================================================= -- helper: ID PLA -with pla as ( - select filament_material_type_id - from filament_material_type - where material_code = 'PLA' -) -insert into filament_variant ( - filament_material_type_id, - variant_display_name, - color_name, - is_matte, - is_special, - cost_chf_per_kg, - stock_spools, - spool_net_kg, - is_active -) -select - pla.filament_material_type_id, - v.variant_display_name, - v.color_name, - v.is_matte, - v.is_special, - 18.00, -- PLA da Excel - v.stock_spools, - 1.000, - true +with pla as (select filament_material_type_id + from filament_material_type + where material_code = 'PLA') +insert +into filament_variant (filament_material_type_id, + variant_display_name, + color_name, + is_matte, + is_special, + cost_chf_per_kg, + stock_spools, + spool_net_kg, + is_active) +select pla.filament_material_type_id, + v.variant_display_name, + v.color_name, + v.is_matte, + v.is_special, + 18.00, -- PLA da Excel + v.stock_spools, + 1.000, + true from pla - cross join ( - values - ('PLA Bianco', 'Bianco', false, false, 3.000::numeric), - ('PLA Nero', 'Nero', false, false, 3.000::numeric), - ('PLA Blu', 'Blu', false, false, 1.000::numeric), - ('PLA Arancione', 'Arancione', 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 Chiaro', 'Grigio chiaro', false, false, 1.000::numeric), - ('PLA Viola', 'Viola', false, false, 1.000::numeric) -) as v(variant_display_name, color_name, is_matte, is_special, stock_spools) + cross join (values ('PLA Bianco', 'Bianco', false, false, 3.000::numeric), + ('PLA Nero', 'Nero', false, false, 3.000::numeric), + ('PLA Blu', 'Blu', false, false, 1.000::numeric), + ('PLA Arancione', 'Arancione', 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 Chiaro', 'Grigio chiaro', false, false, 1.000::numeric), + ('PLA Viola', 'Viola', false, false, + 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 - set - color_name = excluded.color_name, - is_matte = excluded.is_matte, - is_special = excluded.is_special, + set color_name = excluded.color_name, + is_matte = excluded.is_matte, + is_special = excluded.is_special, cost_chf_per_kg = excluded.cost_chf_per_kg, - stock_spools = excluded.stock_spools, - spool_net_kg = excluded.spool_net_kg, - is_active = excluded.is_active; + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; -- ========================================================= -- 5) Ugelli -- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF -- ========================================================= -insert into nozzle_option ( - nozzle_diameter_mm, - owned_quantity, - extra_nozzle_change_fee_chf, - is_active -) values - (0.40, 1, 0.00, true), - (0.60, 1, 50.00, true) +insert into nozzle_option (nozzle_diameter_mm, + owned_quantity, + extra_nozzle_change_fee_chf, + is_active) +values (0.40, 1, 0.00, true), + (0.60, 1, 50.00, true) on conflict (nozzle_diameter_mm) do update - set - owned_quantity = excluded.owned_quantity, + set owned_quantity = excluded.owned_quantity, extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf, - is_active = excluded.is_active; + is_active = excluded.is_active; -- ========================================================= -- 6) Layer heights (opzioni) -- ========================================================= -insert into layer_height_option ( - layer_height_mm, - time_multiplier, - is_active -) values - (0.080, 1.000, true), - (0.120, 1.000, true), - (0.160, 1.000, true), - (0.200, 1.000, true), - (0.240, 1.000, true), - (0.280, 1.000, true) +insert into layer_height_option (layer_height_mm, + time_multiplier, + is_active) +values (0.080, 1.000, true), + (0.120, 1.000, true), + (0.160, 1.000, true), + (0.200, 1.000, true), + (0.240, 1.000, true), + (0.280, 1.000, true) on conflict (layer_height_mm) do update - set - time_multiplier = excluded.time_multiplier, - is_active = excluded.is_active; + set time_multiplier = excluded.time_multiplier, + is_active = excluded.is_active; commit; - - -- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10) update layer_height_option set time_multiplier = 0.1 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//3d-files//.stl + stored_filename text NOT NULL, -- es: .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//attachments//.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); -- 2.49.1 From 96ae9bb6096fe4487683616cb73d3f60eae6b80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 15:32:31 +0100 Subject: [PATCH 02/72] feat(back-end): removed Abstract repository --- .../repository/AbstractAuditableRepository.java | 9 --------- .../repository/AbstractPersistableRepository.java | 9 --------- 2 files changed, 18 deletions(-) delete mode 100644 backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java delete mode 100644 backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java diff --git a/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java b/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java deleted file mode 100644 index 8049dc9..0000000 --- a/backend/src/main/java/com/printcalculator/repository/AbstractAuditableRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.printcalculator.repository; - -import org.springframework.data.jpa.domain.AbstractAuditable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.repository.NoRepositoryBean; - -@NoRepositoryBean -public interface AbstractAuditableRepository extends JpaRepository { -} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java b/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java deleted file mode 100644 index d9e0bc4..0000000 --- a/backend/src/main/java/com/printcalculator/repository/AbstractPersistableRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.printcalculator.repository; - -import org.springframework.data.jpa.domain.AbstractPersistable; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.repository.NoRepositoryBean; - -@NoRepositoryBean -public interface AbstractPersistableRepository extends JpaRepository { -} \ No newline at end of file -- 2.49.1 From 9c3d5fae128211d865b73294b034be65a5b36a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 16:00:03 +0100 Subject: [PATCH 03/72] feat(back-end & ): removed Abstract repository --- .../CustomQuoteRequestController.java | 131 ++++++++ .../controller/OrderController.java | 285 ++++++++++++++++++ .../controller/QuoteSessionController.java | 226 ++++++++++++++ .../entity/CustomQuoteRequestAttachment.java | 2 + .../entity/QuoteSessionTotal.java | 29 -- .../repository/CustomerRepository.java | 2 + .../repository/QuoteLineItemRepository.java | 2 + frontend/src/app/app.routes.ts | 4 + .../features/payment/payment.component.html | 21 ++ .../features/payment/payment.component.scss | 35 +++ .../app/features/payment/payment.component.ts | 34 +++ 11 files changed, 742 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/OrderController.java create mode 100644 backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java delete mode 100644 backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java create mode 100644 frontend/src/app/features/payment/payment.component.html create mode 100644 frontend/src/app/features/payment/payment.component.scss create mode 100644 frontend/src/app/features/payment/payment.component.ts diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java new file mode 100644 index 0000000..56cc1a7 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -0,0 +1,131 @@ +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") +@CrossOrigin(origins = "*") +public class CustomQuoteRequestController { + + private final CustomQuoteRequestRepository requestRepo; + private final CustomQuoteRequestAttachmentRepository attachmentRepo; + + // TODO: Inject Storage Service + private static final String STORAGE_ROOT = "storage_requests"; + + public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, + CustomQuoteRequestAttachmentRepository attachmentRepo) { + this.requestRepo = requestRepo; + this.attachmentRepo = attachmentRepo; + } + + // 1. Create Custom Quote Request + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity createCustomQuoteRequest( + // Form fields + @RequestParam("requestType") String requestType, + @RequestParam("customerType") String customerType, + @RequestParam("email") String email, + @RequestParam(value = "phone", required = false) String phone, + @RequestParam(value = "name", required = false) String name, + @RequestParam(value = "companyName", required = false) String companyName, + @RequestParam(value = "contactPerson", required = false) String contactPerson, + @RequestParam("message") String message, + // Files (Max 15) + @RequestParam(value = "files", required = false) List files + ) throws IOException { + + // 1. Create Request + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setRequestType(requestType); + request.setCustomerType(customerType); + request.setEmail(email); + request.setPhone(phone); + request.setName(name); + request.setCompanyName(companyName); + request.setContactPerson(contactPerson); + request.setMessage(message); + 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 + Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); + Files.createDirectories(absolutePath.getParent()); + Files.copy(file.getInputStream(), absolutePath); + } + } + + return ResponseEntity.ok(request); + } + + // 2. Get Request + @GetMapping("/{id}") + public ResponseEntity 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"; + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java new file mode 100644 index 0000000..763ccb7 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -0,0 +1,285 @@ +package com.printcalculator.controller; + +import com.printcalculator.entity.*; +import com.printcalculator.repository.*; +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 com.fasterxml.jackson.annotation.JsonProperty; + +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.List; +import java.util.UUID; +import java.util.Optional; + +@RestController +@RequestMapping("/api/orders") +@CrossOrigin(origins = "*") +public class OrderController { + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final CustomerRepository customerRepo; + + // TODO: Inject Storage Service or use a base path property + private static final String STORAGE_ROOT = "storage_orders"; + + public OrderController(OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + QuoteSessionRepository quoteSessionRepo, + QuoteLineItemRepository quoteLineItemRepo, + CustomerRepository customerRepo) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.customerRepo = customerRepo; + } + + // DTOs + public static class CreateOrderRequest { + public CustomerDto customer; + public AddressDto billingAddress; + public AddressDto shippingAddress; + public boolean shippingSameAsBilling; + } + + public static class CustomerDto { + public String email; + public String phone; + public String customerType; // "PRIVATE", "BUSINESS" + } + + public static class AddressDto { + public String firstName; + public String lastName; + public String companyName; + public String contactPerson; + public String addressLine1; + public String addressLine2; + public String zip; + public String city; + public String countryCode; + } + + // 1. Create Order from Quote + @PostMapping("/from-quote/{quoteSessionId}") + @Transactional + public ResponseEntity createOrderFromQuote( + @PathVariable UUID quoteSessionId, + @RequestBody CreateOrderRequest request + ) { + // 1. Fetch Quote Session + QuoteSession session = quoteSessionRepo.findById(quoteSessionId) + .orElseThrow(() -> new RuntimeException("Quote Session not found")); + + if (!"ACTIVE".equals(session.getStatus())) { + // Allow converting only active sessions? Or check if not already converted? + // checking convertedOrderId might be better + } + if (session.getConvertedOrderId() != null) { + return ResponseEntity.badRequest().body(null); // Already converted + } + + // 2. Handle Customer (Find or Create) + Customer customer = customerRepo.findByEmail(request.customer.email) + .orElseGet(() -> { + Customer newC = new Customer(); + newC.setEmail(request.customer.email); + newC.setCreatedAt(OffsetDateTime.now()); + return customerRepo.save(newC); + }); + // Update customer details? + customer.setPhone(request.customer.phone); + customer.setCustomerType(request.customer.customerType); + customer.setUpdatedAt(OffsetDateTime.now()); + customerRepo.save(customer); + + // 3. Create Order + Order order = new Order(); + order.setSourceQuoteSession(session); + order.setCustomer(customer); + order.setCustomerEmail(request.customer.email); + order.setCustomerPhone(request.customer.phone); + order.setStatus("PENDING_PAYMENT"); + order.setCreatedAt(OffsetDateTime.now()); + order.setUpdatedAt(OffsetDateTime.now()); + order.setCurrency("CHF"); + + // Billing + order.setBillingCustomerType(request.customer.customerType); + if (request.billingAddress != null) { + order.setBillingFirstName(request.billingAddress.firstName); + order.setBillingLastName(request.billingAddress.lastName); + order.setBillingCompanyName(request.billingAddress.companyName); + order.setBillingContactPerson(request.billingAddress.contactPerson); + order.setBillingAddressLine1(request.billingAddress.addressLine1); + order.setBillingAddressLine2(request.billingAddress.addressLine2); + order.setBillingZip(request.billingAddress.zip); + order.setBillingCity(request.billingAddress.city); + order.setBillingCountryCode(request.billingAddress.countryCode != null ? request.billingAddress.countryCode : "CH"); + } + + // Shipping + order.setShippingSameAsBilling(request.shippingSameAsBilling); + if (!request.shippingSameAsBilling && request.shippingAddress != null) { + order.setShippingFirstName(request.shippingAddress.firstName); + order.setShippingLastName(request.shippingAddress.lastName); + order.setShippingCompanyName(request.shippingAddress.companyName); + order.setShippingContactPerson(request.shippingAddress.contactPerson); + order.setShippingAddressLine1(request.shippingAddress.addressLine1); + order.setShippingAddressLine2(request.shippingAddress.addressLine2); + order.setShippingZip(request.shippingAddress.zip); + order.setShippingCity(request.shippingAddress.city); + order.setShippingCountryCode(request.shippingAddress.countryCode != null ? request.shippingAddress.countryCode : "CH"); + } else { + // Copy billing to shipping? Or leave empty and rely on flag? + // Usually explicit copy is safer for queries + 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()); + } + + // Financials from Session (Assuming mocked/calculated in session) + // We re-calculate totals from line items to be safe + List quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); + + BigDecimal subtotal = BigDecimal.ZERO; + + // Save Order first to get ID + order = orderRepo.save(order); + + // 4. Create Order Items + 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()); // Or per item if supported + + // Pricing + oItem.setUnitPriceChf(qItem.getUnitPriceChf()); + oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); + oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); + oItem.setMaterialGrams(qItem.getMaterialGrams()); + + // File Handling Check + // "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" + UUID fileUuid = UUID.randomUUID(); + String ext = getExtension(qItem.getOriginalFilename()); + String storedFilename = fileUuid.toString() + "." + ext; + + // Note: We don't have the orderItemId yet because we haven't saved it. + // We can pre-generate ID or save order item then update path? + // GeneratedValue strategy AUTO might not let us set ID easily? + // Let's save item first with temporary path, then update? + // OR use a path structure that doesn't depend on ItemId? "orders/{orderId}/3d-files/{uuid}.ext" is also fine? + // User requested: "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" + // So we need OrderItemId. + + oItem.setStoredFilename(storedFilename); + oItem.setStoredRelativePath("PENDING"); // Placeholder + oItem.setMimeType("application/octet-stream"); // specific type if known + + oItem = orderItemRepo.save(oItem); + + // Update Path now that we have ID + String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; + oItem.setStoredRelativePath(relativePath); + orderItemRepo.save(oItem); + + subtotal = subtotal.add(oItem.getLineTotalChf()); + } + + // Update Order Totals + order.setSubtotalChf(subtotal); + order.setSetupCostChf(session.getSetupCostChf()); + order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? + // TODO: Calc implementation for shipping + + BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); + order.setTotalChf(total); + + // Link session + session.setConvertedOrderId(order.getId()); + session.setStatus("CONVERTED"); // or CLOSED + quoteSessionRepo.save(session); + + return ResponseEntity.ok(orderRepo.save(order)); + } + + // 2. Upload file for Order Item + @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity 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(); + } + + // Ensure path logic + String relativePath = item.getStoredRelativePath(); + if (relativePath == null || relativePath.equals("PENDING")) { + // Should verify consistency + // If we used the logic above, it should have a path. + // If it's "PENDING", regen it. + String ext = getExtension(file.getOriginalFilename()); + String storedFilename = UUID.randomUUID().toString() + "." + ext; + relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename; + item.setStoredRelativePath(relativePath); + item.setStoredFilename(storedFilename); + // Update item + } + + // Save file to disk + Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); + Files.createDirectories(absolutePath.getParent()); + + if (Files.exists(absolutePath)) { + Files.delete(absolutePath); // Overwrite? + } + + Files.copy(file.getInputStream(), absolutePath); + + item.setFileSizeBytes(file.getSize()); + item.setMimeType(file.getContentType()); + // Calculate SHA256? (Optional) + + orderItemRepo.save(item); + + return ResponseEntity.ok().build(); + } + + private String getExtension(String filename) { + if (filename == null) return "stl"; + int i = filename.lastIndexOf('.'); + if (i > 0) { + return filename.substring(i + 1); + } + return "stl"; + } + +} diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java new file mode 100644 index 0000000..618338c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -0,0 +1,226 @@ +package com.printcalculator.controller; + +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.model.PrintStats; +import com.printcalculator.model.QuoteResult; +import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.QuoteCalculator; +import com.printcalculator.service.SlicerService; +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.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/quote-sessions") +@CrossOrigin(origins = "*") // Allow CORS for dev +public class QuoteSessionController { + + private final QuoteSessionRepository sessionRepo; + private final QuoteLineItemRepository lineItemRepo; + private final SlicerService slicerService; + private final QuoteCalculator quoteCalculator; + private final PrinterMachineRepository machineRepo; + + // Defaults + private static final String DEFAULT_FILAMENT = "pla_basic"; + private static final String DEFAULT_PROCESS = "standard"; + + public QuoteSessionController(QuoteSessionRepository sessionRepo, + QuoteLineItemRepository lineItemRepo, + SlicerService slicerService, + QuoteCalculator quoteCalculator, + PrinterMachineRepository machineRepo) { + this.sessionRepo = sessionRepo; + this.lineItemRepo = lineItemRepo; + this.slicerService = slicerService; + this.quoteCalculator = quoteCalculator; + this.machineRepo = machineRepo; + } + + // 1. Start a new session with a file + @PostMapping(value = "/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity createSessionAndAddItem( + @RequestParam("file") MultipartFile file + ) throws IOException { + // Create new session + QuoteSession session = new QuoteSession(); + session.setStatus("ACTIVE"); + session.setPricingVersion("v1"); // Placeholder + session.setMaterialCode(DEFAULT_FILAMENT); // Default for session + session.setSupportsEnabled(false); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + // Set defaults + session.setSetupCostChf(BigDecimal.ZERO); + + session = sessionRepo.save(session); + + // Process file and add item + addItemToSession(session, file); + + // Refresh session to return updated data (if we added list fetching to repo, otherwise manually fetch items if needed for response) + // For now, let's just return the session. The client might need to fetch items separately or we can return a DTO. + // User request: "ritorna sessione + line items + total" + // Since QuoteSession entity doesn't have a @OneToMany list of items (it has OneToMany usually but mapped by item), + // we might need a DTO or just rely on the fact that we might add the list to the entity if valid. + // Looking at QuoteSession.java, it does NOT have a list of items. + // So we should probably return a DTO or just return the Session and Client calls GET /quote-sessions/{id} immediately? + // User request: "ritorna quoteSessionId" (actually implies just ID, but likely wants full object). + // "ritorna sessione + line items + total (usa view o calcolo service)" refers to GET /quote-sessions/{id} + + // Let's return the full session details including items in a DTO/Map/wrapper? + // Or just the session for now. The user said "ritorna quoteSessionId" for this specific endpoint. + // Let's return the Session entity for now. + return ResponseEntity.ok(session); + } + + // 2. Add item to existing session + @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity addItemToExistingSession( + @PathVariable UUID id, + @RequestParam("file") MultipartFile file + ) throws IOException { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + QuoteLineItem item = addItemToSession(session, file); + return ResponseEntity.ok(item); + } + + // Helper to add item + private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file) throws IOException { + if (file.isEmpty()) throw new IOException("File is empty"); + + // 1. Save file temporarily + Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename()); + file.transferTo(tempInput.toFile()); + + try { + // 2. Mock Calc or Real Calc + // The user said: "per ora calcolo mock" (mock calculation) but we have SlicerService. + // "Nota: il calcolo può essere stub: set print_time_seconds/material_grams/unit_price_chf a valori placeholder." + // However, since we have the SlicerService, we CAN try to use it if we want, OR just use stub as requested to be fast? + // "avvia calcolo (per ora calcolo mock)" -> I will use a simple Stub to satisfy the requirement immediately. + // But I will also implement the structure to swap to Real Calc. + + // STUB CALCULATION as requested + int printTime = 3600; // 1 hour + BigDecimal materialGrams = new BigDecimal("50.00"); + BigDecimal unitPrice = new BigDecimal("15.00"); + + // 3. Create Line Item + QuoteLineItem item = new QuoteLineItem(); + item.setQuoteSession(session); + item.setOriginalFilename(file.getOriginalFilename()); + item.setQuantity(1); + item.setColorCode("#FFFFFF"); // Default + item.setStatus("CALCULATED"); + + item.setPrintTimeSeconds(printTime); + item.setMaterialGrams(materialGrams); + item.setUnitPriceChf(unitPrice); + item.setPricingBreakdown(Map.of("mock", true)); + + // Set simple bounding box + item.setBoundingBoxXMm(BigDecimal.valueOf(100)); + item.setBoundingBoxYMm(BigDecimal.valueOf(100)); + item.setBoundingBoxZMm(BigDecimal.valueOf(20)); + + item.setCreatedAt(OffsetDateTime.now()); + item.setUpdatedAt(OffsetDateTime.now()); + + return lineItemRepo.save(item); + + } finally { + Files.deleteIfExists(tempInput); + } + } + + // 3. Update Line Item + @PatchMapping("/line-items/{lineItemId}") + @Transactional + public ResponseEntity updateLineItem( + @PathVariable UUID lineItemId, + @RequestBody Map 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 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> getQuoteSession(@PathVariable UUID id) { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + List 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 response = new HashMap<>(); + response.put("session", session); + response.put("items", items); + response.put("itemsTotalChf", itemsTotal); + response.put("grandTotalChf", grandTotal); + + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java index 1c60d24..01f3406 100644 --- a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java +++ b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java @@ -116,4 +116,6 @@ public class CustomQuoteRequestAttachment { this.createdAt = createdAt; } + public void setCustomQuoteRequest(CustomQuoteRequest request) { + } } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java b/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java deleted file mode 100644 index 9a2fce1..0000000 --- a/backend/src/main/java/com/printcalculator/entity/QuoteSessionTotal.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.printcalculator.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import org.hibernate.annotations.Immutable; - -import java.math.BigDecimal; -import java.util.UUID; - -@Entity -@Immutable -@Table(name = "quote_session_totals") -public class QuoteSessionTotal { - @Column(name = "quote_session_id") - private UUID quoteSessionId; - - @Column(name = "total_chf") - private BigDecimal totalChf; - - public UUID getQuoteSessionId() { - return quoteSessionId; - } - - public BigDecimal getTotalChf() { - return totalChf; - } - -} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java index f874743..4aa6cad 100644 --- a/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java @@ -3,7 +3,9 @@ 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 { + Optional findByEmail(String email); } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 2f4d591..809f33b 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -3,7 +3,9 @@ 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 { + List findByQuoteSessionId(UUID quoteSessionId); } \ No newline at end of file diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 183f658..46d2cf3 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -28,6 +28,10 @@ export const routes: Routes = [ { path: '', loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) + }, + { + path: 'payment/:orderId', + loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) } ] } diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html new file mode 100644 index 0000000..733e839 --- /dev/null +++ b/frontend/src/app/features/payment/payment.component.html @@ -0,0 +1,21 @@ +
+ + + payment + Payment Integration + Order #{{ orderId }} + + +
+

Coming Soon

+

The online payment system is currently under development.

+

Your order has been saved. Please contact us to arrange payment.

+
+
+ + + +
+
diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss new file mode 100644 index 0000000..d4475db --- /dev/null +++ b/frontend/src/app/features/payment/payment.component.scss @@ -0,0 +1,35 @@ +.payment-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; + padding: 2rem; + background-color: #f5f5f5; +} + +.payment-card { + max-width: 500px; + width: 100%; +} + +.coming-soon { + text-align: center; + padding: 2rem 0; + + h3 { + margin-bottom: 1rem; + color: #555; + } + + p { + color: #777; + margin-bottom: 0.5rem; + } +} + +mat-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #3f51b5; +} diff --git a/frontend/src/app/features/payment/payment.component.ts b/frontend/src/app/features/payment/payment.component.ts new file mode 100644 index 0000000..671a36e --- /dev/null +++ b/frontend/src/app/features/payment/payment.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'app-payment', + standalone: true, + imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule], + templateUrl: './payment.component.html', + styleUrl: './payment.component.scss' +}) +export class PaymentComponent implements OnInit { + orderId: string | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit(): void { + this.orderId = this.route.snapshot.paramMap.get('orderId'); + } + + completeOrder(): void { + // Simulate payment completion + alert('Payment Simulated! Order marked as PAID.'); + // Here you would call the backend to mark as paid if we had that endpoint ready + // For now, redirect home or show success + this.router.navigate(['/']); + } +} -- 2.49.1 From 89d84ed369fed87c95ec6620271c2d28a875f28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 16:02:03 +0100 Subject: [PATCH 04/72] feat(back-end & web): improvements --- frontend/src/app/app.routes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 46d2cf3..66d3c9a 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -25,13 +25,13 @@ export const routes: Routes = [ path: 'contact', loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) }, - { - path: '', - loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) - }, { path: 'payment/:orderId', loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) + }, + { + path: '', + loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) } ] } -- 2.49.1 From 5a84fb13c08aa5907f11482d8938d9e493a4467f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 16:10:10 +0100 Subject: [PATCH 05/72] feat(back-end): cors config update --- .../main/java/com/printcalculator/config/CorsConfig.java | 9 ++++++++- frontend/src/app/app.routes.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index 2e6f92c..7d15114 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -11,7 +11,14 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1") + .allowedOrigins( + "http://localhost", + "http://localhost:4200", + "http://localhost:80", + "http://127.0.0.1", + "https://dev.3d-fab.ch", + "https://3d-fab.ch" + ) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 66d3c9a..4f51782 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -32,6 +32,10 @@ export const routes: Routes = [ { path: '', loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) + }, + { + path: '**', + redirectTo: '' } ] } -- 2.49.1 From 257c60fa5ea5929378effd51c00055877f00617d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 17:07:26 +0100 Subject: [PATCH 06/72] feat(back-end): refactor session creation --- backend/build.gradle | 2 + .../printcalculator/config/CorsConfig.java | 3 +- .../CustomQuoteRequestController.java | 29 +- .../controller/QuoteSessionController.java | 176 +++++++--- .../printcalculator/dto/PrintSettingsDto.java | 23 ++ .../printcalculator/dto/QuoteRequestDto.java | 15 + .../core/services/quote-request.service.ts | 40 +++ .../services/quote-estimator.service.ts | 317 +++++++----------- .../contact-form/contact-form.component.ts | 38 ++- 9 files changed, 367 insertions(+), 276 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java create mode 100644 frontend/src/app/core/services/quote-request.service.ts diff --git a/backend/build.gradle b/backend/build.gradle index 35b1154..70e9613 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,6 +29,8 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index 7d15114..b3a9869 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -17,9 +17,10 @@ public class CorsConfig implements WebMvcConfigurer { "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") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") .allowCredentials(true); } diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 56cc1a7..1d25df6 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -40,29 +40,20 @@ public class CustomQuoteRequestController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity createCustomQuoteRequest( - // Form fields - @RequestParam("requestType") String requestType, - @RequestParam("customerType") String customerType, - @RequestParam("email") String email, - @RequestParam(value = "phone", required = false) String phone, - @RequestParam(value = "name", required = false) String name, - @RequestParam(value = "companyName", required = false) String companyName, - @RequestParam(value = "contactPerson", required = false) String contactPerson, - @RequestParam("message") String message, - // Files (Max 15) - @RequestParam(value = "files", required = false) List files + @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, + @RequestPart(value = "files", required = false) List files ) throws IOException { // 1. Create Request CustomQuoteRequest request = new CustomQuoteRequest(); - request.setRequestType(requestType); - request.setCustomerType(customerType); - request.setEmail(email); - request.setPhone(phone); - request.setName(name); - request.setCompanyName(companyName); - request.setContactPerson(contactPerson); - request.setMessage(message); + 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()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 618338c..0aad79f 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -28,7 +28,7 @@ import java.util.UUID; @RestController @RequestMapping("/api/quote-sessions") -@CrossOrigin(origins = "*") // Allow CORS for dev + public class QuoteSessionController { private final QuoteSessionRepository sessionRepo; @@ -36,6 +36,7 @@ public class QuoteSessionController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; + private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Defaults private static final String DEFAULT_FILAMENT = "pla_basic"; @@ -45,49 +46,34 @@ public class QuoteSessionController { QuoteLineItemRepository lineItemRepo, SlicerService slicerService, QuoteCalculator quoteCalculator, - PrinterMachineRepository machineRepo) { + PrinterMachineRepository machineRepo, + com.printcalculator.repository.PricingPolicyRepository pricingRepo) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; + this.pricingRepo = pricingRepo; } - // 1. Start a new session with a file - @PostMapping(value = "/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + // 1. Start a new empty session + @PostMapping(value = "") @Transactional - public ResponseEntity createSessionAndAddItem( - @RequestParam("file") MultipartFile file - ) throws IOException { - // Create new session + public ResponseEntity createSession() { QuoteSession session = new QuoteSession(); session.setStatus("ACTIVE"); - session.setPricingVersion("v1"); // Placeholder - session.setMaterialCode(DEFAULT_FILAMENT); // Default for session + 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)); - // Set defaults - session.setSetupCostChf(BigDecimal.ZERO); + + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO); session = sessionRepo.save(session); - - // Process file and add item - addItemToSession(session, file); - - // Refresh session to return updated data (if we added list fetching to repo, otherwise manually fetch items if needed for response) - // For now, let's just return the session. The client might need to fetch items separately or we can return a DTO. - // User request: "ritorna sessione + line items + total" - // Since QuoteSession entity doesn't have a @OneToMany list of items (it has OneToMany usually but mapped by item), - // we might need a DTO or just rely on the fact that we might add the list to the entity if valid. - // Looking at QuoteSession.java, it does NOT have a list of items. - // So we should probably return a DTO or just return the Session and Client calls GET /quote-sessions/{id} immediately? - // User request: "ritorna quoteSessionId" (actually implies just ID, but likely wants full object). - // "ritorna sessione + line items + total (usa view o calcolo service)" refers to GET /quote-sessions/{id} - - // Let's return the full session details including items in a DTO/Map/wrapper? - // Or just the session for now. The user said "ritorna quoteSessionId" for this specific endpoint. - // Let's return the Session entity for now. return ResponseEntity.ok(session); } @@ -96,17 +82,18 @@ public class QuoteSessionController { @Transactional public ResponseEntity addItemToExistingSession( @PathVariable UUID id, - @RequestParam("file") MultipartFile file + @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); + QuoteLineItem item = addItemToSession(session, file, settings); return ResponseEntity.ok(item); } // Helper to add item - private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file) throws IOException { + private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IOException("File is empty"); // 1. Save file temporarily @@ -114,35 +101,87 @@ public class QuoteSessionController { file.transferTo(tempInput.toFile()); try { - // 2. Mock Calc or Real Calc - // The user said: "per ora calcolo mock" (mock calculation) but we have SlicerService. - // "Nota: il calcolo può essere stub: set print_time_seconds/material_grams/unit_price_chf a valori placeholder." - // However, since we have the SlicerService, we CAN try to use it if we want, OR just use stub as requested to be fast? - // "avvia calcolo (per ora calcolo mock)" -> I will use a simple Stub to satisfy the requirement immediately. - // But I will also implement the structure to swap to Real Calc. + // 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")); - // STUB CALCULATION as requested - int printTime = 3600; // 1 hour - BigDecimal materialGrams = new BigDecimal("50.00"); - BigDecimal unitPrice = new BigDecimal("15.00"); + // 2. Pick Profiles + String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" + // If the display name doesn't match the json profile name, we might need a mapping key in DB. + // For now assuming display name works or we use a tough default + machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists. + // Ideally: machine.getSlicerProfileName(); - // 3. Create Line Item + 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"; + } + + 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 + Map 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()); + + // 3. Slice + PrintStats stats = slicerService.slice( + tempInput.toFile(), + machineProfile, + filamentProfile, + processProfile, + null, // machine overrides + processOverrides + ); + + // 4. Calculate Quote + QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); + + // 5. Create Line Item QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); item.setOriginalFilename(file.getOriginalFilename()); item.setQuantity(1); - item.setColorCode("#FFFFFF"); // Default - item.setStatus("CALCULATED"); + item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); + item.setStatus("READY"); // or CALCULATED - item.setPrintTimeSeconds(printTime); - item.setMaterialGrams(materialGrams); - item.setUnitPriceChf(unitPrice); - item.setPricingBreakdown(Map.of("mock", true)); + item.setPrintTimeSeconds((int) stats.printTimeSeconds()); + item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); + item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); - // Set simple bounding box - item.setBoundingBoxXMm(BigDecimal.valueOf(100)); - item.setBoundingBoxYMm(BigDecimal.valueOf(100)); - item.setBoundingBoxZMm(BigDecimal.valueOf(20)); + // Store breakdown + Map 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 + // Cannot get bb from GCodeParser yet? + // If GCodeParser doesn't return size, we might defaults or 0. + // Stats has filament used. + // Let's set dummy for now or upgrade parser later. + item.setBoundingBoxXMm(BigDecimal.ZERO); + item.setBoundingBoxYMm(BigDecimal.ZERO); + item.setBoundingBoxZMm(BigDecimal.ZERO); item.setCreatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now()); @@ -154,6 +193,37 @@ public class QuoteSessionController { } } + 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"); + break; + case "high": + settings.setLayerHeight(0.12); + settings.setInfillDensity(20.0); + settings.setInfillPattern("gyroid"); + break; + case "standard": + default: + settings.setLayerHeight(0.20); + settings.setInfillDensity(20.0); + settings.setInfillPattern("grid"); + break; + } + } 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"); + } + } + // 3. Update Line Item @PatchMapping("/line-items/{lineItemId}") @Transactional diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java new file mode 100644 index 0000000..c63565d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -0,0 +1,23 @@ +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; + private String notes; +} diff --git a/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java new file mode 100644 index 0000000..bd03f4d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java @@ -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; +} diff --git a/frontend/src/app/core/services/quote-request.service.ts b/frontend/src/app/core/services/quote-request.service.ts new file mode 100644 index 0000000..e121b37 --- /dev/null +++ b/frontend/src/app/core/services/quote-request.service.ts @@ -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 { + 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); + } +} diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index fccb3ac..5298e17 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -128,203 +128,135 @@ export class QuoteEstimatorService { } return new Observable(observer => { - const totalItems = request.items.length; - const allProgress: number[] = new Array(totalItems).fill(0); - const finalResponses: any[] = []; - let completedRequests = 0; + // 1. Create Session first + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - 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'); + this.http.post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({ + next: (sessionRes) => { + const sessionId = sessionRes.id; + const sessionSetupCost = sessionRes.setupCostChf || 0; + + // 2. Upload files to this session + const totalItems = request.items.length; + const allProgress: number[] = new Array(totalItems).fill(0); + const finalResponses: any[] = []; + let completedRequests = 0; - 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 checkCompletion = () => { + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); + observer.next(avg); + + if (completedRequests === totalItems) { + finalize(finalResponses, sessionSetupCost); + } + }; + + 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(`${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((100 * event.loaded) / event.total); + checkCompletion(); + } else if (event.type === HttpEventType.Response) { + allProgress[index] = 100; + finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item }; + completedRequests++; + checkCompletion(); + } + }, + error: (err) => { + console.error('Item upload failed', err); + finalResponses[index] = { success: false, fileName: item.file.name }; + completedRequests++; + checkCompletion(); + } + }); + }); + }, + error: (err) => { + console.error('Failed to create session', err); + observer.error('Could not initialize quote session'); + } + }); + + const finalize = (responses: any[], setupCost: number) => { + observer.next(100); + const items: QuoteItem[] = []; + let grandTotal = 0; + let totalTime = 0; + let totalWeight = 0; + let validCount = 0; + + responses.forEach((res, idx) => { + if (!res || !res.success) return; + validCount++; + + const unitPrice = res.unitPriceChf || 0; + const quantity = res.originalQty || 1; + + items.push({ + fileName: res.fileName, + unitPrice: unitPrice, + unitTime: res.printTimeSeconds || 0, + unitWeight: res.materialGrams || 0, + quantity: quantity, + material: request.material, + color: res.originalItem.color || 'Default' + }); + + grandTotal += unitPrice * quantity; + totalTime += (res.printTimeSeconds || 0) * quantity; + totalWeight += (res.materialGrams || 0) * quantity; + }); + + if (validCount === 0) { + observer.error('All calculations failed.'); + return; } - const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + grandTotal += setupCost; - return this.http.post(`${environment.apiUrl}/api/quote`, formData, { - headers, - reportProgress: true, - observe: 'events' - }).pipe( - map(event => ({ item, event, index })), - catchError(err => of({ item, error: err, index })) - ); - }); - - // Subscribe to all - uploads.forEach((obs) => { - obs.subscribe({ - next: (wrapper: any) => { - const idx = wrapper.index; - - if (wrapper.error) { - finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; - } - - const event = wrapper.event; - if (event && event.type === HttpEventType.UploadProgress) { - if (event.total) { - const percent = Math.round((100 * event.loaded) / event.total); - allProgress[idx] = percent; - // Emit average progress - const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); - 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) { - // All done - 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 totalWeight = 0; - - items.forEach(item => { - grandTotal += item.unitPrice * item.quantity; - totalTime += item.unitTime * item.quantity; - totalWeight += item.unitWeight * item.quantity; - }); - - const totalHours = Math.floor(totalTime / 3600); - const totalMinutes = Math.ceil((totalTime % 3600) / 60); - - const result: QuoteResult = { - items, - setupCost: useBackendSetup ? setupCostFromBackend! : setupCost, - currency: currencyFromBackend || 'CHF', - totalPrice: Math.round(grandTotal * 100) / 100, - totalTimeHours: totalHours, - totalTimeMinutes: totalMinutes, - totalWeight: Math.ceil(totalWeight), - notes: request.notes - }; - - observer.next(result); - observer.complete(); - } - } - }, - error: (err) => { - console.error('Error in request subscription', err); - completedRequests++; - if (completedRequests === totalItems) { - observer.error('Requests failed'); - } - } - }); - }); + const result: QuoteResult = { + items, + setupCost: setupCost, + currency: 'CHF', + totalPrice: Math.round(grandTotal * 100) / 100, + totalTimeHours: Math.floor(totalTime / 3600), + totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), + totalWeight: Math.ceil(totalWeight), + notes: request.notes + }; + + observer.next(result); + observer.complete(); + }; }); } - 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 { const m = mat.toUpperCase(); if (m.includes('PLA')) return 'pla_basic'; @@ -333,13 +265,6 @@ export class QuoteEstimatorService { 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 private pendingConsultation = signal<{files: File[], message: string} | null>(null); diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts index 30eec60..651208a 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts @@ -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 { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; +import { QuoteRequestService } from '../../../../core/services/quote-request.service'; interface FilePreview { file: File; @@ -37,6 +38,8 @@ export class ContactFormComponent { { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } ]; + private quoteRequestService = inject(QuoteRequestService); + constructor( private fb: FormBuilder, private translate: TranslateService, @@ -156,13 +159,34 @@ export class ContactFormComponent { onSubmit() { if (this.form.valid) { - const formData = { - ...this.form.value, - files: this.files().map(f => f.file) - }; - console.log('Form Submit:', formData); + const formVal = this.form.value; + const isCompany = formVal.isCompany; + + 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); + }, + error: (err) => { + console.error('Submission failed', err); + alert('Error submitting request. Please try again.'); + } + }); - this.sent.set(true); } else { this.form.markAllAsTouched(); } -- 2.49.1 From 044fba8d5a4b4ec156fadd6f4d7424ca3dc56a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 19:01:48 +0100 Subject: [PATCH 07/72] feat(back-end & front-end): checkout, update form structure, add new DTOs, refactor order logic --- .../printcalculator/BackendApplication.java | 2 + .../CustomQuoteRequestController.java | 1 - .../controller/OrderController.java | 110 +++---- .../controller/QuoteSessionController.java | 30 +- .../com/printcalculator/dto/AddressDto.java | 16 + .../dto/CreateOrderRequest.java | 11 + .../com/printcalculator/dto/CustomerDto.java | 10 + .../printcalculator/entity/QuoteLineItem.java | 12 + frontend/src/app/app.routes.ts | 4 + .../calculator/calculator-page.component.html | 10 +- .../calculator/calculator-page.component.ts | 26 +- .../quote-result/quote-result.component.ts | 3 +- .../services/quote-estimator.service.ts | 33 +- .../features/checkout/checkout.component.html | 151 +++++++++ .../features/checkout/checkout.component.scss | 292 ++++++++++++++++++ .../features/checkout/checkout.component.ts | 187 +++++++++++ .../app-input/app-input.component.scss | 2 +- 17 files changed, 813 insertions(+), 87 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/AddressDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/CustomerDto.java create mode 100644 frontend/src/app/features/checkout/checkout.component.html create mode 100644 frontend/src/app/features/checkout/checkout.component.scss create mode 100644 frontend/src/app/features/checkout/checkout.component.ts diff --git a/backend/src/main/java/com/printcalculator/BackendApplication.java b/backend/src/main/java/com/printcalculator/BackendApplication.java index f8209a7..7ade0a3 100644 --- a/backend/src/main/java/com/printcalculator/BackendApplication.java +++ b/backend/src/main/java/com/printcalculator/BackendApplication.java @@ -2,8 +2,10 @@ package com.printcalculator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication +@EnableTransactionManagement public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 1d25df6..e67de4f 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -21,7 +21,6 @@ import java.util.UUID; @RestController @RequestMapping("/api/custom-quote-requests") -@CrossOrigin(origins = "*") public class CustomQuoteRequestController { private final CustomQuoteRequestRepository requestRepo; diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 763ccb7..c546d1a 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -21,7 +21,6 @@ import java.util.Optional; @RestController @RequestMapping("/api/orders") -@CrossOrigin(origins = "*") public class OrderController { private final OrderRepository orderRepo; @@ -45,38 +44,13 @@ public class OrderController { this.customerRepo = customerRepo; } - // DTOs - public static class CreateOrderRequest { - public CustomerDto customer; - public AddressDto billingAddress; - public AddressDto shippingAddress; - public boolean shippingSameAsBilling; - } - - public static class CustomerDto { - public String email; - public String phone; - public String customerType; // "PRIVATE", "BUSINESS" - } - - public static class AddressDto { - public String firstName; - public String lastName; - public String companyName; - public String contactPerson; - public String addressLine1; - public String addressLine2; - public String zip; - public String city; - public String countryCode; - } // 1. Create Order from Quote @PostMapping("/from-quote/{quoteSessionId}") @Transactional public ResponseEntity createOrderFromQuote( @PathVariable UUID quoteSessionId, - @RequestBody CreateOrderRequest request + @RequestBody com.printcalculator.dto.CreateOrderRequest request ) { // 1. Fetch Quote Session QuoteSession session = quoteSessionRepo.findById(quoteSessionId) @@ -91,16 +65,16 @@ public class OrderController { } // 2. Handle Customer (Find or Create) - Customer customer = customerRepo.findByEmail(request.customer.email) + Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail()) .orElseGet(() -> { Customer newC = new Customer(); - newC.setEmail(request.customer.email); + newC.setEmail(request.getCustomer().getEmail()); newC.setCreatedAt(OffsetDateTime.now()); return customerRepo.save(newC); }); // Update customer details? - customer.setPhone(request.customer.phone); - customer.setCustomerType(request.customer.customerType); + customer.setPhone(request.getCustomer().getPhone()); + customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setUpdatedAt(OffsetDateTime.now()); customerRepo.save(customer); @@ -108,39 +82,39 @@ public class OrderController { Order order = new Order(); order.setSourceQuoteSession(session); order.setCustomer(customer); - order.setCustomerEmail(request.customer.email); - order.setCustomerPhone(request.customer.phone); + 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"); // Billing - order.setBillingCustomerType(request.customer.customerType); - if (request.billingAddress != null) { - order.setBillingFirstName(request.billingAddress.firstName); - order.setBillingLastName(request.billingAddress.lastName); - order.setBillingCompanyName(request.billingAddress.companyName); - order.setBillingContactPerson(request.billingAddress.contactPerson); - order.setBillingAddressLine1(request.billingAddress.addressLine1); - order.setBillingAddressLine2(request.billingAddress.addressLine2); - order.setBillingZip(request.billingAddress.zip); - order.setBillingCity(request.billingAddress.city); - order.setBillingCountryCode(request.billingAddress.countryCode != null ? request.billingAddress.countryCode : "CH"); + 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"); } // Shipping - order.setShippingSameAsBilling(request.shippingSameAsBilling); - if (!request.shippingSameAsBilling && request.shippingAddress != null) { - order.setShippingFirstName(request.shippingAddress.firstName); - order.setShippingLastName(request.shippingAddress.lastName); - order.setShippingCompanyName(request.shippingAddress.companyName); - order.setShippingContactPerson(request.shippingAddress.contactPerson); - order.setShippingAddressLine1(request.shippingAddress.addressLine1); - order.setShippingAddressLine2(request.shippingAddress.addressLine2); - order.setShippingZip(request.shippingAddress.zip); - order.setShippingCity(request.shippingAddress.city); - order.setShippingCountryCode(request.shippingAddress.countryCode != null ? request.shippingAddress.countryCode : "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 { // Copy billing to shipping? Or leave empty and rely on flag? // Usually explicit copy is safer for queries @@ -185,14 +159,6 @@ public class OrderController { String ext = getExtension(qItem.getOriginalFilename()); String storedFilename = fileUuid.toString() + "." + ext; - // Note: We don't have the orderItemId yet because we haven't saved it. - // We can pre-generate ID or save order item then update path? - // GeneratedValue strategy AUTO might not let us set ID easily? - // Let's save item first with temporary path, then update? - // OR use a path structure that doesn't depend on ItemId? "orders/{orderId}/3d-files/{uuid}.ext" is also fine? - // User requested: "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" - // So we need OrderItemId. - oItem.setStoredFilename(storedFilename); oItem.setStoredRelativePath("PENDING"); // Placeholder oItem.setMimeType("application/octet-stream"); // specific type if known @@ -202,6 +168,24 @@ public class OrderController { // Update Path now that we have ID String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; oItem.setStoredRelativePath(relativePath); + + // COPY FILE from Quote to Order + if (qItem.getStoredPath() != null) { + try { + Path sourcePath = Paths.get(qItem.getStoredPath()); + if (Files.exists(sourcePath)) { + Path targetPath = Paths.get(STORAGE_ROOT, relativePath); + Files.createDirectories(targetPath.getParent()); + Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + oItem.setFileSizeBytes(Files.size(targetPath)); + } + } catch (IOException e) { + e.printStackTrace(); // Log error but allow order creation? Or fail? + // Ideally fail or mark as error + } + } + orderItemRepo.save(oItem); subtotal = subtotal.add(oItem.getLineTotalChf()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 0aad79f..540405d 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -20,6 +20,7 @@ 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; @@ -96,9 +97,21 @@ public class QuoteSessionController { private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IOException("File is empty"); - // 1. Save file temporarily - Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename()); - file.transferTo(tempInput.toFile()); + // 1. Define Persistent Storage Path + // Structure: storage_quotes/{sessionId}/{uuid}.{ext} + String storageDir = "storage_quotes/" + session.getId(); + Files.createDirectories(Paths.get(storageDir)); + + String originalFilename = file.getOriginalFilename(); + String ext = originalFilename != null && originalFilename.contains(".") + ? originalFilename.substring(originalFilename.lastIndexOf(".")) + : ".stl"; + + String storedFilename = UUID.randomUUID() + ext; + Path persistentPath = Paths.get(storageDir, storedFilename); + + // Save file + Files.copy(file.getInputStream(), persistentPath); try { // Apply Basic/Advanced Logic @@ -142,9 +155,9 @@ public class QuoteSessionController { if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); - // 3. Slice + // 3. Slice (Use persistent path) PrintStats stats = slicerService.slice( - tempInput.toFile(), + persistentPath.toFile(), machineProfile, filamentProfile, processProfile, @@ -159,6 +172,7 @@ public class QuoteSessionController { 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 @@ -188,8 +202,10 @@ public class QuoteSessionController { return lineItemRepo.save(item); - } finally { - Files.deleteIfExists(tempInput); + } catch (Exception e) { + // Cleanup if failed + Files.deleteIfExists(persistentPath); + throw e; } } diff --git a/backend/src/main/java/com/printcalculator/dto/AddressDto.java b/backend/src/main/java/com/printcalculator/dto/AddressDto.java new file mode 100644 index 0000000..3b1c748 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AddressDto.java @@ -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; +} diff --git a/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java new file mode 100644 index 0000000..24f5800 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java @@ -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; +} diff --git a/backend/src/main/java/com/printcalculator/dto/CustomerDto.java b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java new file mode 100644 index 0000000..432394f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java @@ -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" +} diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index ec526b3..c29260c 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -24,6 +24,7 @@ public class QuoteLineItem { @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) @@ -64,6 +65,9 @@ public class QuoteLineItem { @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; @@ -184,6 +188,14 @@ public class QuoteLineItem { this.errorMessage = errorMessage; } + public String getStoredPath() { + return storedPath; + } + + public void setStoredPath(String storedPath) { + this.storedPath = storedPath; + } + public OffsetDateTime getCreatedAt() { return createdAt; } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4f51782..c74044d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -25,6 +25,10 @@ export const routes: Routes = [ path: 'contact', 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) diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 3de51d9..928f957 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -11,14 +11,6 @@
-} @else if (step() === 'details' && result()) { -
- - -
} @else {
@@ -63,7 +55,7 @@ [result]="result()!" (consult)="onConsult()" (proceed)="onProceed()" - (itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)" + (itemChange)="onItemChange($event)" > } @else { diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index dc08f1e..4b0e196 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -7,14 +7,13 @@ import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.c import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component'; 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 { Router, ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-calculator-page', standalone: true, - imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent], + imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent], templateUrl: './calculator-page.component.html', styleUrl: './calculator-page.component.scss' }) @@ -82,13 +81,34 @@ export class CalculatorPageComponent implements OnInit { } 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() { this.step.set('quote'); } + onItemChange(event: {id?: string, fileName: string, quantity: number}) { + // 1. Update local form for consistency (UI feedback) + if (this.uploadForm) { + this.uploadForm.updateItemQuantityByName(event.fileName, 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) + }); + } + } + onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index daeb3cd..8d3e193 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -18,7 +18,7 @@ export class QuoteResultComponent { result = input.required(); consult = output(); proceed = output(); - itemChange = output<{fileName: string, quantity: number}>(); + itemChange = output<{id?: string, fileName: string, quantity: number}>(); // Local mutable state for items to handle quantity changes items = signal([]); @@ -42,6 +42,7 @@ export class QuoteResultComponent { }); this.itemChange.emit({ + id: this.items()[index].id, fileName: this.items()[index].fileName, quantity: qty }); diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 5298e17..496ab32 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -18,6 +18,7 @@ export interface QuoteRequest { } export interface QuoteItem { + id?: string; fileName: string; unitPrice: number; unitTime: number; // seconds @@ -28,6 +29,7 @@ export interface QuoteItem { } export interface QuoteResult { + sessionId?: string; items: QuoteItem[]; setupCost: number; currency: string; @@ -119,6 +121,29 @@ export class QuoteEstimatorService { }) ); } + + // NEW METHODS for Order Flow + + getQuoteSession(sessionId: string): Observable { + 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 { + 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 }); + } + + createOrder(sessionId: string, orderDetails: any): Observable { + 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 }); + } calculate(request: QuoteRequest): Observable { console.log('QuoteEstimatorService: Calculating quote...', request); @@ -149,7 +174,7 @@ export class QuoteEstimatorService { observer.next(avg); if (completedRequests === totalItems) { - finalize(finalResponses, sessionSetupCost); + finalize(finalResponses, sessionSetupCost, sessionId); } }; @@ -203,7 +228,7 @@ export class QuoteEstimatorService { } }); - const finalize = (responses: any[], setupCost: number) => { + const finalize = (responses: any[], setupCost: number, sessionId: string) => { observer.next(100); const items: QuoteItem[] = []; let grandTotal = 0; @@ -219,6 +244,7 @@ export class QuoteEstimatorService { const quantity = res.originalQty || 1; items.push({ + id: res.id, fileName: res.fileName, unitPrice: unitPrice, unitTime: res.printTimeSeconds || 0, @@ -226,6 +252,8 @@ export class QuoteEstimatorService { quantity: quantity, material: request.material, color: res.originalItem.color || 'Default' + // Store ID if needed for updates? QuoteItem interface might need update + // or we map it in component }); grandTotal += unitPrice * quantity; @@ -241,6 +269,7 @@ export class QuoteEstimatorService { grandTotal += setupCost; const result: QuoteResult = { + sessionId: sessionId, items, setupCost: setupCost, currency: 'CHF', diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html new file mode 100644 index 0000000..1b8168d --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -0,0 +1,151 @@ +
+

Checkout

+ +
+ + +
+ +
+ {{ error }} +
+ +
+ + +
+
+

Contact Information

+
+
+ + +
+
+ Private +
+
+ Company +
+
+ +
+ + +
+
+
+ + +
+
+

Billing Address

+
+
+
+ + +
+ + + + + + + +
+ + + +
+
+
+ + +
+ +
+ + +
+
+

Shipping Address

+
+
+
+ + +
+ + + + + +
+ + + +
+
+
+ +
+ + {{ isSubmitting() ? 'Processing...' : 'Place Order' }} + +
+ +
+
+ + +
+
+
+

Order Summary

+
+ +
+
+
+
+ {{ item.originalFilename }} +
+ Qty: {{ item.quantity }} + +
+
+ {{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g +
+
+
+ {{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} +
+
+
+ +
+
+ Subtotal + {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }} +
+
+ Setup Fee + {{ session.setupCostChf | currency:'CHF' }} +
+
+
+ Total + {{ session.totalPrice | currency:'CHF' }} +
+
+
+
+
+ +
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss new file mode 100644 index 0000000..c4e5074 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -0,0 +1,292 @@ +.checkout-page { + padding: 3rem 1rem; + max-width: 1200px; + margin: 0 auto; +} + +.checkout-layout { + display: grid; + grid-template-columns: 1fr 380px; + gap: var(--space-8); + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + gap: var(--space-6); + } +} + +.section-title { + font-size: 2rem; + font-weight: 700; + margin-bottom: var(--space-6); + color: var(--color-heading); +} + +.form-card { + margin-bottom: var(--space-6); + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + + .card-header { + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-subtle); + + h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-heading); + margin: 0; + } + } + + .card-content { + padding: var(--space-6); + } +} + +.form-row { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-4); + + &.three-cols { + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: var(--space-4); + } + + app-input { + flex: 1; + width: 100%; + } + + @media (max-width: 600px) { + flex-direction: column; + &.three-cols { + grid-template-columns: 1fr; + } + } +} + +/* User Type Selector Styles */ +.user-type-selector { + display: flex; + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); + padding: 4px; + margin-bottom: var(--space-4); + gap: 4px; + width: 100%; +} + +.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); + } +} + +.shipping-option { + margin: var(--space-6) 0; +} + +/* Custom Checkbox */ +.checkbox-container { + display: flex; + align-items: center; + position: relative; + padding-left: 30px; + cursor: pointer; + font-size: 1rem; + 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: 20px; + width: 20px; + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + transition: all 0.2s; + + &:after { + content: ""; + position: absolute; + display: none; + left: 6px; + top: 2px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:hover input ~ .checkmark { + border-color: var(--color-brand); + } +} + + +.checkout-summary-section { + position: relative; +} + +.sticky-card { + position: sticky; + top: 0; + /* Inherits styles from .form-card */ +} + +.summary-items { + margin-bottom: var(--space-6); + max-height: 400px; + overflow-y: auto; +} + +.summary-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--space-3) 0; + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + + .item-details { + flex: 1; + + .item-name { + display: block; + font-weight: 500; + 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: 12px; + height: 12px; + 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-heading); + } +} + +.summary-totals { + background: var(--color-bg-subtle); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-top: var(--space-4); + + .total-row { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-2); + color: var(--color-text); + + &.grand-total { + color: var(--color-heading); + font-weight: 700; + font-size: 1.25rem; + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border); + margin-bottom: 0; + } + } + + .divider { + display: none; // Handled by border-top in grand-total + } +} + +.actions { + margin-top: var(--space-6); + display: flex; + justify-content: flex-end; + + app-button { + width: 100%; + + @media (min-width: 900px) { + width: auto; + min-width: 200px; + } + } +} + +.error-message { + color: var(--color-danger); + background: var(--color-danger-subtle); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); + border: 1px solid var(--color-danger); +} + diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts new file mode 100644 index 0000000..8336061 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -0,0 +1,187 @@ +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 { 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'; + +@Component({ + selector: 'app-checkout', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + AppInputComponent, + AppButtonComponent + ], + templateUrl: './checkout.component.html', + styleUrls: ['./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(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.'; + } + }); + } +} diff --git a/frontend/src/app/shared/components/app-input/app-input.component.scss b/frontend/src/app/shared/components/app-input/app-input.component.scss index 18eb1d7..e5de85e 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.scss +++ b/frontend/src/app/shared/components/app-input/app-input.component.scss @@ -1,6 +1,6 @@ .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); } -.required-mark { color: var(--color-danger-500); margin-left: 2px; } +.required-mark { color: var(--color-text); margin-left: 2px; } .form-control { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); -- 2.49.1 From 44f9408b22da606cc9eaf7fa4c00dc3b4533c822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 19:24:58 +0100 Subject: [PATCH 08/72] feat(back-end & front-end): add session file --- .../controller/QuoteSessionController.java | 32 ++++++ .../calculator/calculator-page.component.ts | 104 +++++++++++++++++- .../upload-form/upload-form.component.ts | 53 +++++++++ .../services/quote-estimator.service.ts | 41 +++++++ 4 files changed, 229 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 540405d..ce4261d 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -26,6 +26,8 @@ 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; @RestController @RequestMapping("/api/quote-sessions") @@ -309,4 +311,34 @@ public class QuoteSessionController { return ResponseEntity.ok(response); } + + // 6. Download Line Item Content + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") + public ResponseEntity 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()); + 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); + } } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 4b0e196..4a3d4c0 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -1,6 +1,8 @@ import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; 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 { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; @@ -43,6 +45,95 @@ export class CalculatorPageComponent implements OnInit { this.mode.set(data['mode']); } }); + + this.route.queryParams.subscribe(params => { + const sessionId = params['session']; + if (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(true); + 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' })); + + if (this.uploadForm) { + this.uploadForm.setFiles(files); + this.uploadForm.patchSettings(session); + + // Also restore colors? + // setFiles inits with 'Black'. We need to update them if they differ. + // items has colorCode. + 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); + } + }); + } + }); + } + 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) { @@ -67,10 +158,21 @@ export class CalculatorPageComponent implements OnInit { this.uploadProgress.set(event); } else { // It's the result - this.result.set(event as QuoteResult); + const res = event as QuoteResult; + this.result.set(res); this.loading.set(false); this.uploadProgress.set(100); this.step.set('quote'); + + // Update URL with session ID without reloading + if (res.sessionId) { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { session: res.sessionId }, + queryParamsHandling: 'merge', // merge with existing params like 'mode' if any + replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update" + }); + } } }, error: () => { diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 726a4a5..917284c 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -232,6 +232,59 @@ export class UploadFormComponent implements OnInit { }); } + setFiles(files: File[]) { + const validItems: FormItem[] = []; + for (const file of files) { + // Default color is Black or derive from somewhere if possible, but here we just init + validItems.push({ file, quantity: 1, color: 'Black' }); + } + + 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() { console.log('UploadFormComponent: onSubmit triggered'); console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 496ab32..9d8668d 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -306,4 +306,45 @@ export class QuoteEstimatorService { this.pendingConsultation.set(null); // Clear after reading return data; } + + // Session File Retrieval + getLineItemContent(sessionId: string, lineItemId: string): Observable { + 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 + })), + 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 + }; + } } -- 2.49.1 From 2eea773ee2295fd2c9d0742bd858229def6495e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 21:25:29 +0100 Subject: [PATCH 09/72] feat(back-end): files saved in disc --- .../printcalculator/BackendApplication.java | 3 + .../repository/QuoteSessionRepository.java | 2 + .../service/SessionCleanupService.java | 79 +++++++++++++++++++ docker-compose.deploy.yml | 2 + 4 files changed, 86 insertions(+) create mode 100644 backend/src/main/java/com/printcalculator/service/SessionCleanupService.java diff --git a/backend/src/main/java/com/printcalculator/BackendApplication.java b/backend/src/main/java/com/printcalculator/BackendApplication.java index 7ade0a3..e9ee050 100644 --- a/backend/src/main/java/com/printcalculator/BackendApplication.java +++ b/backend/src/main/java/com/printcalculator/BackendApplication.java @@ -2,10 +2,13 @@ package com.printcalculator; import org.springframework.boot.SpringApplication; 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 @EnableTransactionManagement +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java index ba18585..51f4640 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -3,7 +3,9 @@ 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 { + List findByCreatedAtBefore(java.time.OffsetDateTime cutoff); } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java new file mode 100644 index 0000000..ef333fe --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java @@ -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 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 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); + } + } + } +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 5adbe15..0b2fcbd 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -18,6 +18,8 @@ services: restart: always volumes: - 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 frontend: -- 2.49.1 From 701a10e8868e44f0e43cf0cc55365436635bd3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 17 Feb 2026 15:35:17 +0100 Subject: [PATCH 10/72] fix(back-end): shift model --- .../service/SlicerService.java | 26 ++++++++++++++----- docker-compose.deploy.yml | 3 +-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 573b668..f09bccf 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -135,12 +136,25 @@ public class SlicerService { Thread.currentThread().interrupt(); throw new IOException("Interrupted during slicing", e); } finally { - // Cleanup temp dir - // In production we should delete, for debugging we might want to keep? - // Let's delete for now on success. - // recursiveDelete(tempDir); - // Leaving it effectively "leaks" temp, but safer for persistent debugging? - // Implementation detail: Use a utility to clean up. + deleteRecursively(tempDir); + } + } + + private void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + + try (var walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + logger.warning("Failed to delete temp path " + p + ": " + e.getMessage()); + } + }); + } catch (IOException e) { + logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage()); } } } diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 0b2fcbd..777bdc4 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -7,8 +7,6 @@ services: container_name: print-calculator-backend-${ENV} ports: - "${BACKEND_PORT}:8000" - env_file: - - .env environment: - DB_URL=${DB_URL} - DB_USERNAME=${DB_USERNAME} @@ -20,6 +18,7 @@ services: - 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 + - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests frontend: -- 2.49.1 From 85a4db1630429c760c1fa027e400bbf7362ed392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 17 Feb 2026 16:25:21 +0100 Subject: [PATCH 11/72] fix(back-end): shift model --- .../controller/OrderController.java | 8 +++++++- .../printcalculator/service/SlicerService.java | 16 ++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index c546d1a..2bc9dd9 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -88,6 +88,12 @@ public class OrderController { order.setCreatedAt(OffsetDateTime.now()); order.setUpdatedAt(OffsetDateTime.now()); order.setCurrency("CHF"); + // Initialize all NOT NULL monetary fields before first persist. + order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSubtotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); // Billing order.setBillingCustomerType(request.getCustomer().getCustomerType()); @@ -193,7 +199,7 @@ public class OrderController { // Update Order Totals order.setSubtotalChf(subtotal); - order.setSetupCostChf(session.getSetupCostChf()); + order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? // TODO: Calc implementation for shipping diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index f09bccf..ecafa8d 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -10,6 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -79,8 +80,7 @@ public class SlicerService { command.add("--load-filaments"); command.add(fFile.getAbsolutePath()); command.add("--ensure-on-bed"); - command.add("--arrange"); - command.add("1"); // force arrange + // Single-model jobs do not need arrange; it can fail on near-limit models. command.add("--slice"); command.add("0"); // slice plate 0 command.add("--outputdir"); @@ -95,19 +95,23 @@ public class SlicerService { // 4. Run Process ProcessBuilder pb = new ProcessBuilder(command); pb.directory(tempDir.toFile()); - // pb.inheritIO(); // Useful for debugging, but maybe capture instead? + Path slicerLogPath = tempDir.resolve("orcaslicer.log"); + pb.redirectErrorStream(true); + pb.redirectOutput(slicerLogPath.toFile()); Process process = pb.start(); boolean finished = process.waitFor(5, TimeUnit.MINUTES); if (!finished) { - process.destroy(); + process.destroyForcibly(); throw new IOException("Slicer timed out"); } if (process.exitValue() != 0) { - // Read stderr - String error = new String(process.getErrorStream().readAllBytes()); + String error = ""; + if (Files.exists(slicerLogPath)) { + error = Files.readString(slicerLogPath, StandardCharsets.UTF_8); + } throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); } -- 2.49.1 From 46eb980e242751c24a49b2a15b8d96ae10d14836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 17 Feb 2026 16:32:46 +0100 Subject: [PATCH 12/72] fix(back-end): shift model --- .../service/SlicerService.java | 136 ++++++++++-------- 1 file changed, 73 insertions(+), 63 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index ecafa8d..78d6aec 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -65,76 +65,75 @@ public class SlicerService { mapper.writeValue(fFile, filamentProfile); mapper.writeValue(pFile, processProfile); - // 3. Build Command - // --load-settings "machine.json;process.json" --load-filaments "filament.json" - List command = new ArrayList<>(); - command.add(slicerPath); - - // Load machine settings - command.add("--load-settings"); - command.add(mFile.getAbsolutePath()); - - // Load process settings - command.add("--load-settings"); - command.add(pFile.getAbsolutePath()); - command.add("--load-filaments"); - command.add(fFile.getAbsolutePath()); - command.add("--ensure-on-bed"); - // Single-model jobs do not need arrange; it can fail on near-limit models. - command.add("--slice"); - command.add("0"); // slice plate 0 - command.add("--outputdir"); - 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(inputStl.getAbsolutePath()); - - logger.info("Executing Slicer: " + String.join(" ", command)); - - // 4. Run Process - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(tempDir.toFile()); - Path slicerLogPath = tempDir.resolve("orcaslicer.log"); - pb.redirectErrorStream(true); - pb.redirectOutput(slicerLogPath.toFile()); - - Process process = pb.start(); - boolean finished = process.waitFor(5, TimeUnit.MINUTES); - - if (!finished) { - process.destroyForcibly(); - throw new IOException("Slicer timed out"); - } - - if (process.exitValue() != 0) { - String error = ""; - if (Files.exists(slicerLogPath)) { - error = Files.readString(slicerLogPath, StandardCharsets.UTF_8); - } - 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); + Path slicerLogPath = tempDir.resolve("orcaslicer.log"); + + // 3. Run slicer. Retry with arrange only for out-of-volume style failures. + for (boolean useArrange : new boolean[]{false, true}) { + List command = new ArrayList<>(); + command.add(slicerPath); + command.add("--load-settings"); + command.add(mFile.getAbsolutePath()); + command.add("--load-settings"); + command.add(pFile.getAbsolutePath()); + command.add("--load-filaments"); + command.add(fFile.getAbsolutePath()); + command.add("--ensure-on-bed"); + if (useArrange) { + command.add("--arrange"); + command.add("1"); } + command.add("--slice"); + command.add("0"); + command.add("--outputdir"); + command.add(tempDir.toAbsolutePath().toString()); + command.add(inputStl.getAbsolutePath()); + + logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command)); + + Files.deleteIfExists(slicerLogPath); + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(tempDir.toFile()); + pb.redirectErrorStream(true); + pb.redirectOutput(slicerLogPath.toFile()); + + Process process = pb.start(); + boolean finished = process.waitFor(5, TimeUnit.MINUTES); + + if (!finished) { + process.destroyForcibly(); + throw new IOException("Slicer timed out"); + } + + if (process.exitValue() != 0) { + String error = ""; + if (Files.exists(slicerLogPath)) { + error = Files.readString(slicerLogPath, StandardCharsets.UTF_8); + } + if (!useArrange && isOutOfVolumeError(error)) { + logger.warning("Slicer reported model out of printable area, retrying with arrange."); + continue; + } + throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); + } + + File gcodeFile = tempDir.resolve(basename + ".gcode").toFile(); + if (!gcodeFile.exists()) { + File alt = tempDir.resolve("plate_1.gcode").toFile(); + if (alt.exists()) { + gcodeFile = alt; + } else { + throw new IOException("GCode output not found in " + tempDir); + } + } + + return gCodeParser.parse(gcodeFile); } - // 6. Parse Results - return gCodeParser.parse(gcodeFile); + throw new IOException("Slicer failed after retry"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -161,4 +160,15 @@ public class SlicerService { logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage()); } } + + private boolean isOutOfVolumeError(String errorLog) { + if (errorLog == null || errorLog.isBlank()) { + return false; + } + + String normalized = errorLog.toLowerCase(); + return normalized.contains("nothing to be sliced") + || normalized.contains("no object is fully inside the print volume") + || normalized.contains("calc_exclude_triangles"); + } } -- 2.49.1 From bb269d84a5e91cc3b739d1c72bb6530356a38889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 17 Feb 2026 16:34:40 +0100 Subject: [PATCH 13/72] fix(back-end): shift model --- .gitea/workflows/cicd.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 386d3cd..d5d5f80 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -21,16 +21,6 @@ jobs: java-version: '21' 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 run: | cd backend -- 2.49.1 From ec77b76abb5f3b97d567889b80313d400beaf54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 19:33:57 +0100 Subject: [PATCH 14/72] fix(back-end): try fix profile manager --- .../service/ProfileManager.java | 71 ++++++++++++++++--- .../service/SlicerService.java | 6 ++ 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/service/ProfileManager.java b/backend/src/main/java/com/printcalculator/service/ProfileManager.java index c6bd78a..9df3f45 100644 --- a/backend/src/main/java/com/printcalculator/service/ProfileManager.java +++ b/backend/src/main/java/com/printcalculator/service/ProfileManager.java @@ -16,6 +16,7 @@ import java.util.logging.Logger; import java.util.stream.Stream; import java.util.Map; import java.util.HashMap; +import java.util.List; @Service public class ProfileManager { @@ -56,22 +57,38 @@ public class ProfileManager { if (profilePath == null) { throw new IOException("Profile not found: " + profileName); } + logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath); return resolveInheritance(profilePath); } private Path findProfileFile(String name, String type) { // Check aliases first String resolvedName = profileAliases.getOrDefault(name, name); - - // Simple search: look for name.json in the profiles_root recursively - // Type could be "machine", "process", "filament" to narrow down, but for now global search - String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json"; - + + // Look for name.json under the expected type directory first to avoid + // collisions across vendors/profile families with same filename. + String filename = toJsonFilename(resolvedName); + try (Stream stream = Files.walk(Paths.get(profilesRoot))) { - Optional found = stream + List candidates = stream .filter(p -> p.getFileName().toString().equals(filename)) - .findFirst(); - return found.orElse(null); + .sorted() + .toList(); + + if (candidates.isEmpty()) { + return null; + } + + if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) { + Optional typed = candidates.stream() + .filter(p -> pathContainsSegment(p, type)) + .findFirst(); + if (typed.isPresent()) { + return typed.get(); + } + } + + return candidates.get(0); } catch (IOException e) { logger.severe("Error searching for profile: " + e.getMessage()); return null; @@ -85,14 +102,20 @@ public class ProfileManager { // 2. Check inherits if (currentNode.has("inherits")) { String parentName = currentNode.get("inherits").asText(); - // Try to find parent in same directory or standard search - Path parentPath = currentPath.getParent().resolve(parentName); + // Try local directory first with explicit .json filename. + String parentFilename = toJsonFilename(parentName); + Path parentPath = currentPath.getParent().resolve(parentFilename); if (!Files.exists(parentPath)) { - // If not in same dir, search globally + // Fallback to the same profile type directory before global. + String inferredType = inferTypeFromPath(currentPath); + parentPath = findProfileFile(parentName, inferredType); + } + if (parentPath == null || !Files.exists(parentPath)) { parentPath = findProfileFile(parentName, "any"); } if (parentPath != null && Files.exists(parentPath)) { + logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath); // Recursive call ObjectNode parentNode = resolveInheritance(parentPath); // Merge current into parent (child overrides parent) @@ -123,4 +146,30 @@ public class ProfileManager { mainNode.set(fieldName, jsonNode); } } + + private String toJsonFilename(String name) { + return name.endsWith(".json") ? name : name + ".json"; + } + + private boolean pathContainsSegment(Path path, String segment) { + String normalized = path.toString().replace('\\', '/'); + String needle = "/" + segment + "/"; + return normalized.contains(needle); + } + + private String inferTypeFromPath(Path path) { + if (path == null) { + return "any"; + } + if (pathContainsSegment(path, "machine")) { + return "machine"; + } + if (pathContainsSegment(path, "process")) { + return "process"; + } + if (pathContainsSegment(path, "filament")) { + return "filament"; + } + return "any"; + } } diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 78d6aec..117f965 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -46,6 +46,12 @@ public class SlicerService { ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament"); ObjectNode processProfile = profileManager.getMergedProfile(processName, "process"); + logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'"); + logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area") + + ", printable_height=" + machineProfile.path("printable_height") + + ", bed_exclude_area=" + machineProfile.path("bed_exclude_area") + + ", head_wrap_detect_zone=" + machineProfile.path("head_wrap_detect_zone")); + // Apply Overrides if (machineOverrides != null) { machineOverrides.forEach(machineProfile::put); -- 2.49.1 From 797b10e4ad0ac48b2d6f4d51e797799b8532d6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 19:49:56 +0100 Subject: [PATCH 15/72] fix(back-end): try fix profile manager --- .../profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json | 6 +++--- .../profiles/BBL/machine/fdm_bbl_3dp_001_common.json | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json index 6b75717..8c8a2ff 100644 --- a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json +++ b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json @@ -56,7 +56,7 @@ "nozzle_height": "4.76", "nozzle_type": "stainless_steel", "nozzle_volume": "92", - "printable_height": "256", + "printable_height": "260", "printer_structure": "i3", "retract_lift_below": [ "255" @@ -64,7 +64,7 @@ "scan_first_layer": "0", "machine_start_gcode": ";===== machine: A1 =========================\n;===== date: 20240620 =====================\nG392 S0\nM9833.2\n;M400\n;M73 P1.717\n\n;===== start to heat heatbead&hotend==========\nM1002 gcode_claim_action : 2\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM104 S140\nM140 S[bed_temperature_initial_layer_single]\n\n;=====start printer sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A0 B10 L100 C37 D10 M60 E37 F10 N60\nM1006 A0 B10 L100 C41 D10 M60 E41 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A43 B10 L100 C46 D10 M70 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C43 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C41 D10 M80 E41 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E44 F10 N80\nM1006 A0 B10 L100 C49 D10 M80 E49 F10 N80\nM1006 A0 B10 L100 C0 D10 M80 E0 F10 N80\nM1006 A44 B10 L100 C48 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A43 B10 L100 C46 D10 M60 E39 F10 N80\nM1006 W\nM18 \n;=====start printer sound ===================\n\n;=====avoid end stop =================\nG91\nG380 S2 Z40 F1200\nG380 S3 Z-15 F1200\nG90\n\n;===== reset machine status =================\n;M290 X39 Y39 Z8\nM204 S6000\n\nM630 S0 P0\nG91\nM17 Z0.3 ; lower the z-motor current\n\nG90\nM17 X0.65 Y1.2 Z0.6 ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\n;M211 X0 Y0 Z0 ; turn off soft endstop to prevent protential logic problem\n\n;====== cog noise reduction=================\nM982.2 S1 ; turn on cog noise reduction\n\nM1002 gcode_claim_action : 13\n\nG28 X\nG91\nG1 Z5 F1200\nG90\nG0 X128 F30000\nG0 Y254 F3000\nG91\nG1 Z-5 F1200\n\nM109 S25 H140\n\nM17 E0.3\nM83\nG1 E10 F1200\nG1 E-0.5 F30\nM17 D\n\nG28 Z P0 T140; home z with low precision,permit 300deg temperature\nM104 S{nozzle_temperature_initial_layer[initial_extruder]}\n\nM1002 judge_flag build_plate_detect_flag\nM622 S1\n G39.4\n G90\n G1 Z5 F1200\nM623\n\n;M400\n;M73 P1.717\n\n;===== prepare print temperature and material ==========\nM1002 gcode_claim_action : 24\n\nM400\n;G392 S1\nM211 X0 Y0 Z0 ;turn off soft endstop\nM975 S1 ; turn on\n\nG90\nG1 X-28.5 F30000\nG1 X-48.2 F3000\n\nM620 M ;enable remap\nM620 S[initial_no_support_extruder]A ; switch material if AMS exist\n M1002 gcode_claim_action : 4\n M400\n M1002 set_filament_type:UNKNOWN\n M109 S[nozzle_temperature_initial_layer]\n M104 S250\n M400\n T[initial_no_support_extruder]\n G1 X-48.2 F3000\n M400\n\n M620.1 E F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60} T{nozzle_temperature_range_high[initial_no_support_extruder]}\n M109 S250 ;set nozzle to common flush temp\n M106 P1 S0\n G92 E0\n G1 E50 F200\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM621 S[initial_no_support_extruder]A\n\nM109 S{nozzle_temperature_range_high[initial_no_support_extruder]} H300\nG92 E0\nG1 E50 F200 ; lower extrusion speed to avoid clog\nM400\nM106 P1 S178\nG92 E0\nG1 E5 F200\nM104 S{nozzle_temperature_initial_layer[initial_no_support_extruder]}\nG92 E0\nG1 E-0.5 F300\n\nG1 X-28.5 F30000\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\n\n;G392 S0\n\nM400\nM106 P1 S0\n;===== prepare print temperature and material end =====\n\n;M400\n;M73 P1.717\n\n;===== auto extrude cali start =========================\nM975 S1\n;G392 S1\n\nG90\nM83\nT1000\nG1 X-48.2 Y0 Z10 F10000\nM400\nM1002 set_filament_type:UNKNOWN\n\nM412 S1 ; ===turn on filament runout detection===\nM400 P10\nM620.3 W1; === turn on filament tangle detection===\nM400 S2\n\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\n\n;M1002 set_flag extrude_cali_flag=1\nM1002 judge_flag extrude_cali_flag\n\nM622 J1\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_extruder]}\n G1 E10 F{outer_wall_volumetric_speed/2.4*60}\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\n\n M1002 judge_last_extrude_cali_success\n M622 J0\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n M400\n M106 P1 S0\n M623\n \n G1 X-48.2 F3000\n M400\n M984 A0.1 E1 S1 F{outer_wall_volumetric_speed/2.4} H[nozzle_diameter]\n M106 P1 S178\n M400 S7\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\nM623 ; end of \"draw extrinsic para cali paint\"\n\n;G392 S0\n;===== auto extrude cali end ========================\n\n;M400\n;M73 P1.717\n\nM104 S170 ; prepare to wipe nozzle\nM106 S255 ; turn on fan\n\n;===== mech mode fast check start =====================\nM1002 gcode_claim_action : 3\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q1 A5 K0 O3\nM974 Q1 S2 P0\n\nM970.2 Q1 K1 W58 Z0.1\nM974 S2\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q0 A10 K0 O1\nM974 Q0 S2 P0\n\nM970.2 Q0 K1 W78 Z0.1\nM974 S2\n\nM975 S1\nG1 F30000\nG1 X0 Y5\nG28 X ; re-home XY\n\nG1 Z4 F1200\n\n;===== mech mode fast check end =======================\n\n;M400\n;M73 P1.717\n\n;===== wipe nozzle ===============================\nM1002 gcode_claim_action : 14\n\nM975 S1\nM106 S255 ; turn on fan (G28 has turn off fan)\nM211 S; push soft endstop status\nM211 X0 Y0 Z0 ;turn off Z axis endstop\n\n;===== remove waste by touching start =====\n\nM104 S170 ; set temp down to heatbed acceptable\n\nM83\nG1 E-1 F500\nG90\nM83\n\nM109 S170\nG0 X108 Y-0.5 F30000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X110 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X112 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X114 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X116 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X118 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X120 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X122 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X124 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X126 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X128 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X130 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X132 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X134 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X136 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X138 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X140 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X142 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X144 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X146 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X148 F10000\nG380 S3 Z-5 F1200\n\nG1 Z5 F30000\n;===== remove waste by touching end =====\n\nG1 Z10 F1200\nG0 X118 Y261 F30000\nG1 Z5 F1200\nM109 S{nozzle_temperature_initial_layer[initial_extruder]-50}\n\nG28 Z P0 T300; home z with low precision,permit 300deg temperature\nG29.2 S0 ; turn off ABL\nM104 S140 ; prepare to abl\nG0 Z5 F20000\n\nG0 X128 Y261 F20000 ; move to exposed steel surface\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nG90\nG1 Z10 F1200\n\n;===== brush material wipe nozzle =====\n\nG90\nG1 Y250 F30000\nG1 X55\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X-35 F30000\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Z5.000 F1200\n\nG90\nG1 X30 Y250.000 F30000\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X35 F30000\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Z10.000 F1200\n\n;===== brush material wipe nozzle end =====\n\nG90\n;G0 X128 Y261 F20000 ; move to exposed steel surface\nG1 Y250 F30000\nG1 X138\nG1 Y261\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nM109 S140\nM106 S255 ; turn on fan (G28 has turn off fan)\n\nM211 R; pop softend status\n\n;===== wipe nozzle end ================================\n\n;M400\n;M73 P1.717\n\n;===== bed leveling ==================================\nM1002 judge_flag g29_before_print_flag\n\nG90\nG1 Z5 F1200\nG1 X0 Y0 F30000\nG29.2 S1 ; turn on ABL\n\nM190 S[bed_temperature_initial_layer_single]; ensure bed temp\nM109 S140\nM106 S0 ; turn off fan , too noisy\n\nM622 J1\n M1002 gcode_claim_action : 1\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n M400\n M500 ; save cali data\nM623\n;===== bed leveling end ================================\n\n;===== home after wipe mouth============================\nM1002 judge_flag g29_before_print_flag\nM622 J0\n\n M1002 gcode_claim_action : 13\n G28\n\nM623\n\n;===== home after wipe mouth end =======================\n\n;M400\n;M73 P1.717\n\nG1 X108.000 Y-0.500 F30000\nG1 Z0.300 F1200\nM400\nG2814 Z0.32\n\nM104 S{nozzle_temperature_initial_layer[initial_extruder]} ; prepare to print\n\n;===== nozzle load line ===============================\n;G90\n;M83\n;G1 Z5 F1200\n;G1 X88 Y-0.5 F20000\n;G1 Z0.3 F1200\n\n;M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n\n;G1 E2 F300\n;G1 X168 E4.989 F6000\n;G1 Z1 F1200\n;===== nozzle load line end ===========================\n\n;===== extrude cali test ===============================\n\nM400\n M900 S\n M900 C\n G90\n M83\n\n M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n G0 X128 E8 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\n\nM900 R\n\nM1002 judge_flag extrude_cali_flag\nM622 J1\n G90\n G1 X108.000 Y1.000 F30000\n G91\n G1 Z-0.700 F1200\n G90\n M83\n G0 X128 E10 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\nM623\n\nG1 Z0.2\n\n;M400\n;M73 P1.717\n\n;========turn off light and wait extrude temperature =============\nM1002 gcode_claim_action : 0\nM400\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n;curr_bed_type={curr_bed_type}\n{if curr_bed_type==\"Textured PEI Plate\"}\nG29.1 Z{-0.02} ; for Textured PEI Plate\n{endif}\n\nM960 S1 P0 ; turn off laser\nM960 S2 P0 ; turn off laser\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off big fan\nM106 P3 S0 ; turn off chamber fan\n\nM975 S1 ; turn on mech mode supression\nG90\nM83\nT1000\n\nM211 X0 Y0 Z0 ;turn off soft endstop\n;G392 S1 ; turn on clog detection\nM1007 S1 ; turn on mass estimation\nG29.4\n", "machine_end_gcode": ";===== date: 20231229 =====================\nG392 S0 ;turn off nozzle clog detect\n\nM400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-0.8 F1800 ; retract\nG1 Z{max_layer_z + 0.5} F900 ; lower z a little\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-13.0 F3000 ; move to safe pos\n{if !spiral_mode && print_sequence != \"by object\"}\nM1002 judge_flag timelapse_record_flag\nM622 J1\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM991 S0 P-1 ;end timelapse at safe pos\nM623\n{endif}\n\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off remote part cooling fan\nM106 P3 S0 ; turn off chamber cooling fan\n\n;G1 X27 F15000 ; wipe\n\n; pull back filament to AMS\nM620 S255\nG1 X267 F15000\nT255\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nM621 S255\n\nM104 S0 ; turn off hotend\n\nM400 ; wait all motion done\nM17 S\nM17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom\n{if (max_layer_z + 100.0) < 256}\n G1 Z{max_layer_z + 100.0} F600\n G1 Z{max_layer_z +98.0}\n{else}\n G1 Z256 F600\n G1 Z256\n{endif}\nM400 P100\nM17 R ; restore z current\n\nG90\nG1 X-48 Y180 F3600\n\nM220 S100 ; Reset feedrate magnitude\nM201.2 K1.0 ; Reset acc magnitude\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 0\n\n;=====printer finish sound=========\nM17\nM400 S1\nM1006 S1\nM1006 A0 B20 L100 C37 D20 M40 E42 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C46 D10 M80 E46 F10 N80\nM1006 A44 B20 L100 C39 D20 M60 E48 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C48 D10 M60 E44 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A44 B20 L100 C49 D20 M80 E41 F20 N80\nM1006 A0 B20 L100 C0 D20 M60 E0 F20 N80\nM1006 A0 B20 L100 C37 D20 M30 E37 F20 N60\nM1006 W\n;=====printer finish sound=========\n\n;M17 X0.8 Y0.8 Z0.5 ; lower motor current to 45% power\nM400\nM18 X Y Z\n\n", - "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", + "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nG92 E0\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", "time_lapse_gcode": ";===================== date: 20240606 =====================\n{if !spiral_mode && print_sequence != \"by object\"}\n; don't support timelapse gcode in spiral_mode and by object sequence for I3 structure printer\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\nG92 E0\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 Z{max_layer_z + 0.4}\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-48.2 F3000 ; move to safe pos\nM400 P300\nM971 S11 C11 O0\nG92 E0\nG1 X0 F18000\nM623\n\nM622.1 S1\nM1002 judge_flag g39_3rd_layer_detect_flag\nM622 J1\n ; enable nozzle clog detect at 3rd layer\n {if layer_num == 2}\n M400\n G90\n M83\n M204 S5000\n G0 Z2 F4000\n G0 X261 Y250 F20000\n M400 P200\n G39 S1\n G0 Z2 F4000\n {endif}\n\n\n M622.1 S1\n M1002 judge_flag g39_detection_flag\n M622 J1\n {if !in_head_wrap_detect_zone}\n M622.1 S0\n M1002 judge_flag g39_mass_exceed_flag\n M622 J1\n {if layer_num > 2}\n G392 S0\n M400\n G90\n M83\n M204 S5000\n G0 Z{max_layer_z + 0.4} F4000\n G39.3 S1\n G0 Z{max_layer_z + 0.4} F4000\n G392 S0\n {endif}\n M623\n {endif}\n M623\nM623\n{endif}\n", "change_filament_gcode": ";===== A1 20240913 =======================\nM1007 S0 ; turn off mass estimation\nG392 S0\nM620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1}\nG17\nG2 Z{max_layer_z + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\n\nG1 X267 F18000\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E-{retraction_distances_when_cut[previous_extruder]} F1200\n{else}\nM620.11 S0\n{endif}\nM400\n\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nM620.10 A0 F[old_filament_e_feedrate]\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\nM620.10 A1 F[new_filament_e_feedrate] L[flush_length] H[nozzle_diameter] T[nozzle_temperature_range_high]\n\nG1 Y128 F9000\n\n{if next_extruder < 255}\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E{retraction_distances_when_cut[previous_extruder]} F{old_filament_e_feedrate}\nM628 S1\nG92 E0\nG1 E{retraction_distances_when_cut[previous_extruder]} F[old_filament_e_feedrate]\nM400\nM629 S1\n{else}\nM620.11 S0\n{endif}\n\nM400\nG92 E0\nM628 S0\n\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM1002 set_filament_type:UNKNOWN\nM109 S[nozzle_temperature_range_high]\nM106 P1 S60\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\nM400\nM1002 set_filament_type:{filament_type[next_extruder]}\n{endif}\n\n{if flush_length_1 > 45 && flush_length_2 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_2 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 45 && flush_length_3 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_3 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 45 && flush_length_4 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_4 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n\nM629\n\nM400\nM106 P1 S60\nM109 S[new_filament_temp]\nG1 E6 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nG1 Z{max_layer_z + 3.0} F3000\nM106 P1 S0\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\n\nM622.1 S0\nM9833 F{outer_wall_volumetric_speed/2.4} A0.3 ; cali dynamic extrusion compensation\nM1002 judge_flag filament_need_cali_flag\nM622 J1\n G92 E0\n G1 E-[new_retract_length_toolchange] F1800\n M400\n \n M106 P1 S178\n M400 S4\n G1 X-38.2 F18000\n G1 X-48.2 F3000\n G1 X-38.2 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-38.2 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0 \nM623\n\nM621 S[next_extruder]A\nG392 S0\n\nM1007 S1\n" -} \ No newline at end of file +} diff --git a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json index dfdb9e7..470dc12 100644 --- a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json +++ b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json @@ -10,9 +10,9 @@ "printer_variant": "0.4", "printable_area": [ "0x0", - "256x0", - "256x256", - "0x256" + "260x0", + "260x260", + "0x260" ], "auxiliary_fan": "1", "bed_exclude_area": [ @@ -139,4 +139,4 @@ "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", "change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A", "machine_pause_gcode": "M400 U1" -} \ No newline at end of file +} -- 2.49.1 From 8364ad0671a8580e46bb096f8b4096343cca7e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 19:50:21 +0100 Subject: [PATCH 16/72] fix(back-end): try fix profile manager --- .../profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json | 2 +- .../profiles/BBL/machine/fdm_bbl_3dp_001_common.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json index 8c8a2ff..409e7c2 100644 --- a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json +++ b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json @@ -56,7 +56,7 @@ "nozzle_height": "4.76", "nozzle_type": "stainless_steel", "nozzle_volume": "92", - "printable_height": "260", + "printable_height": "256", "printer_structure": "i3", "retract_lift_below": [ "255" diff --git a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json index 470dc12..a19df24 100644 --- a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json +++ b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json @@ -10,9 +10,9 @@ "printer_variant": "0.4", "printable_area": [ "0x0", - "260x0", - "260x260", - "0x260" + "256x0", + "256x256", + "0x256" ], "auxiliary_fan": "1", "bed_exclude_area": [ -- 2.49.1 From d28609ee95cb9ee1304f5d66c5ab90d0c3645b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 20:07:41 +0100 Subject: [PATCH 17/72] fix(back-end): try fix profile manager --- .../profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json index 409e7c2..af260c3 100644 --- a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json +++ b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json @@ -21,12 +21,7 @@ "extruder_clearance_height_to_rod": "25", "extruder_clearance_max_radius": "73", "extruder_clearance_dist_to_rod": "56.5", - "head_wrap_detect_zone": [ - "226x224", - "256x224", - "256x256", - "226x256" - ], + "head_wrap_detect_zone": [], "machine_load_filament_time": "25", "machine_max_acceleration_extruding": [ "12000", -- 2.49.1 From 7bb94da45b5872853df106110f400841bb527b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 21:48:09 +0100 Subject: [PATCH 18/72] fix(back-end): try fix profile manager --- .gitea/workflows/cicd.yaml | 9 +++++++-- backend/entrypoint.sh | 22 +++++++++++++++++----- docker-compose.deploy.yml | 16 ++++++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index d5d5f80..b422270 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -125,7 +125,7 @@ jobs: ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null - - name: Write env to server + - name: Write env and compose to server shell: bash run: | # 1. Start with the static env file content @@ -154,9 +154,14 @@ jobs: echo "Preparing to send env file with variables:" grep -v "PASSWORD" /tmp/full_env.env || true - # 5. Send to server + # 5. Send env to server ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ "setenv ${{ env.ENV }}" < /tmp/full_env.env + + # 6. Send docker-compose.deploy.yml to server + # We use a trick to find the base_dir: we look where the script usually puts things + cat docker-compose.deploy.yml | ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ + "cat > /mnt/cache/appdata/print-calculator/docker-compose-deploy.yml 2>/dev/null || cat > /mnt/user/appdata/print-calculator/docker-compose-deploy.yml" diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 1dcaaa2..8013e19 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -3,13 +3,25 @@ echo "----------------------------------------------------------------" echo "Starting Backend Application" echo "DB_URL: $DB_URL" echo "DB_USERNAME: $DB_USERNAME" +echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SLICER_PATH: $SLICER_PATH" echo "--- ALL ENV VARS ---" env echo "----------------------------------------------------------------" -# Exec java with explicit properties from env -exec java -jar app.jar \ - --spring.datasource.url="${DB_URL}" \ - --spring.datasource.username="${DB_USERNAME}" \ - --spring.datasource.password="${DB_PASSWORD}" +# Determine which environment variables to use for database connection +# This allows compatibility with different docker-compose configurations +FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}" +FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}" +FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}" + +if [ -n "$FINAL_DB_URL" ]; then + echo "Using database URL: $FINAL_DB_URL" + exec java -jar app.jar \ + --spring.datasource.url="${FINAL_DB_URL}" \ + --spring.datasource.username="${FINAL_DB_USER}" \ + --spring.datasource.password="${FINAL_DB_PASS}" +else + echo "No database URL specified in environment, relying on application.properties defaults." + exec java -jar app.jar +fi diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 777bdc4..435e8ea 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -8,18 +8,25 @@ services: ports: - "${BACKEND_PORT}:8000" environment: + - SPRING_PROFILES_ACTIVE=${ENV} - DB_URL=${DB_URL} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always - volumes: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: - 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 - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests - + extra_hosts: + - "host.docker.internal:host-gateway" frontend: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} @@ -29,6 +36,11 @@ services: depends_on: - backend restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" volumes: backend_profiles_prod: -- 2.49.1 From e7daf7939490612aed15bf97985fbea0df3cfdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 21:53:34 +0100 Subject: [PATCH 19/72] fix(back-end): try fix profile manager --- .gitea/workflows/cicd.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index b422270..0830c81 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -159,9 +159,8 @@ jobs: "setenv ${{ env.ENV }}" < /tmp/full_env.env # 6. Send docker-compose.deploy.yml to server - # We use a trick to find the base_dir: we look where the script usually puts things - cat docker-compose.deploy.yml | ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ - "cat > /mnt/cache/appdata/print-calculator/docker-compose-deploy.yml 2>/dev/null || cat > /mnt/user/appdata/print-calculator/docker-compose-deploy.yml" + ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ + "setcompose ${{ env.ENV }}" < docker-compose.deploy.yml -- 2.49.1 From 0ddfed4f07fd7c4fcab4bd9f4be46f049e752ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 21:57:23 +0100 Subject: [PATCH 20/72] fix(back-end): try fix profile manager --- .gitea/workflows/cicd.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 0830c81..ac3cc93 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -146,9 +146,12 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - # 3. Append DB credentials + # 3. Append DB and Docker credentials printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env + + printf 'REGISTRY_URL=%s\nREPO_OWNER=%s\nTAG=%s\n' \ + "${{ secrets.REGISTRY_URL }}" "$OWNER_LOWER" "$TAG" >> /tmp/full_env.env # 4. Debug: print content (for debug purposes) echo "Preparing to send env file with variables:" -- 2.49.1 From 87f43f22396a83a9c508d470da65b327eaf790e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 22:00:06 +0100 Subject: [PATCH 21/72] fix(deploy): update env --- .gitea/workflows/cicd.yaml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index ac3cc93..c96391d 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -128,10 +128,20 @@ jobs: - name: Write env and compose to server shell: bash run: | - # 1. Start with the static env file content + # 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV) + if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then + DEPLOY_TAG="prod" + elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then + DEPLOY_TAG="int" + else + DEPLOY_TAG="dev" + fi + DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]') + + # 2. Start with the static env file content cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env - # 2. Determine DB credentials + # 3. Determine DB credentials if [[ "${{ env.ENV }}" == "prod" ]]; then DB_URL="${{ secrets.DB_URL_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}" @@ -146,14 +156,14 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - # 3. Append DB and Docker credentials + # 4. Append DB and Docker credentials printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env printf 'REGISTRY_URL=%s\nREPO_OWNER=%s\nTAG=%s\n' \ - "${{ secrets.REGISTRY_URL }}" "$OWNER_LOWER" "$TAG" >> /tmp/full_env.env + "${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env - # 4. Debug: print content (for debug purposes) + # 5. Debug: print content (for debug purposes) echo "Preparing to send env file with variables:" grep -v "PASSWORD" /tmp/full_env.env || true -- 2.49.1 From 2189e58cc6e57b6fb4216830cbcf8250fdea21c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Wed, 18 Feb 2026 22:04:22 +0100 Subject: [PATCH 22/72] fix(deploy): update env --- .gitea/workflows/cicd.yaml | 6 +++--- docker-compose.deploy.yml | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index c96391d..b1b875f 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -156,11 +156,11 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - # 4. Append DB and Docker credentials - printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ + # 4. Append DB and Docker credentials (quoted) + printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \ "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env - printf 'REGISTRY_URL=%s\nREPO_OWNER=%s\nTAG=%s\n' \ + printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \ "${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env # 5. Debug: print content (for debug purposes) diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 435e8ea..47df56f 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: backend: # L'immagine usa il tag specificato nel file .env o passato da riga di comando -- 2.49.1 From 0d23521cacbb286bbcf67e9647ea2d2609942835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 19 Feb 2026 15:59:33 +0100 Subject: [PATCH 23/72] feat(back-end): add ClamAV service remember to add env and compose.deploy on server --- backend/build.gradle | 1 + .../CustomQuoteRequestController.java | 8 ++- .../controller/OrderController.java | 8 ++- .../controller/QuoteController.java | 7 +- .../controller/QuoteSessionController.java | 8 ++- .../exception/GlobalExceptionHandler.java | 27 ++++++++ .../exception/VirusDetectedException.java | 7 ++ .../service/ClamAVService.java | 64 +++++++++++++++++++ deploy/envs/dev.env | 3 + deploy/envs/int.env | 3 + deploy/envs/prod.env | 3 + docker-compose.deploy.yml | 3 + docker-compose.yml | 11 ++++ 13 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java create mode 100644 backend/src/main/java/com/printcalculator/service/ClamAVService.java diff --git a/backend/build.gradle b/backend/build.gradle index 70e9613..72fed37 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -30,6 +30,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly 'org.projectlombok:lombok' + implementation 'xyz.capybara:clamav-client:2.1.2' annotationProcessor 'org.projectlombok:lombok' } diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index e67de4f..d06fc11 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -25,14 +25,17 @@ public class CustomQuoteRequestController { private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo; + private final com.printcalculator.service.ClamAVService clamAVService; // TODO: Inject Storage Service private static final String STORAGE_ROOT = "storage_requests"; public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, - CustomQuoteRequestAttachmentRepository attachmentRepo) { + CustomQuoteRequestAttachmentRepository attachmentRepo, + com.printcalculator.service.ClamAVService clamAVService) { this.requestRepo = requestRepo; this.attachmentRepo = attachmentRepo; + this.clamAVService = clamAVService; } // 1. Create Custom Quote Request @@ -68,6 +71,9 @@ public class CustomQuoteRequestController { for (MultipartFile file : files) { if (file.isEmpty()) continue; + // Scan for virus + clamAVService.scan(file.getInputStream()); + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); attachment.setRequest(request); attachment.setOriginalFilename(file.getOriginalFilename()); diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 2bc9dd9..2d4e637 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -28,6 +28,7 @@ public class OrderController { private final QuoteSessionRepository quoteSessionRepo; private final QuoteLineItemRepository quoteLineItemRepo; private final CustomerRepository customerRepo; + private final com.printcalculator.service.ClamAVService clamAVService; // TODO: Inject Storage Service or use a base path property private static final String STORAGE_ROOT = "storage_orders"; @@ -36,12 +37,14 @@ public class OrderController { OrderItemRepository orderItemRepo, QuoteSessionRepository quoteSessionRepo, QuoteLineItemRepository quoteLineItemRepo, - CustomerRepository customerRepo) { + CustomerRepository customerRepo, + com.printcalculator.service.ClamAVService clamAVService) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; this.quoteLineItemRepo = quoteLineItemRepo; this.customerRepo = customerRepo; + this.clamAVService = clamAVService; } @@ -229,6 +232,9 @@ public class OrderController { if (!item.getOrder().getId().equals(orderId)) { return ResponseEntity.badRequest().build(); } + + // Scan for virus + clamAVService.scan(file.getInputStream()); // Ensure path logic String relativePath = item.getStoredRelativePath(); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 018b613..45369c1 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -22,15 +22,17 @@ public class QuoteController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; + private final com.printcalculator.service.ClamAVService clamAVService; // Defaults (using aliases defined in ProfileManager) private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_PROCESS = "standard"; - public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) { + public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; + this.clamAVService = clamAVService; } @PostMapping("/api/quote") @@ -99,6 +101,9 @@ public class QuoteController { return ResponseEntity.badRequest().build(); } + // Scan for virus + clamAVService.scan(file.getInputStream()); + // Fetch Default Active Machine PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() .orElseThrow(() -> new IOException("No active printer found in database")); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index ce4261d..b58c224 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -40,6 +40,7 @@ public class QuoteSessionController { private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; + private final com.printcalculator.service.ClamAVService clamAVService; // Defaults private static final String DEFAULT_FILAMENT = "pla_basic"; @@ -50,13 +51,15 @@ public class QuoteSessionController { SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, - com.printcalculator.repository.PricingPolicyRepository pricingRepo) { + com.printcalculator.repository.PricingPolicyRepository pricingRepo, + com.printcalculator.service.ClamAVService clamAVService) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; this.pricingRepo = pricingRepo; + this.clamAVService = clamAVService; } // 1. Start a new empty session @@ -99,6 +102,9 @@ public class QuoteSessionController { private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IOException("File is empty"); + // Scan for virus + clamAVService.scan(file.getInputStream()); + // 1. Define Persistent Storage Path // Structure: storage_quotes/{sessionId}/{uuid}.{ext} String storageDir = "storage_quotes/" + session.getId(); diff --git a/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..360d5f5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package com.printcalculator.exception; + +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.context.request.WebRequest; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(VirusDetectedException.class) + public ResponseEntity handleVirusDetectedException( + VirusDetectedException ex, WebRequest request) { + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("message", ex.getMessage()); + body.put("error", "Virus Detected"); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java b/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java new file mode 100644 index 0000000..6b64216 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java @@ -0,0 +1,7 @@ +package com.printcalculator.exception; + +public class VirusDetectedException extends RuntimeException { + public VirusDetectedException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/ClamAVService.java new file mode 100644 index 0000000..dc6532a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/ClamAVService.java @@ -0,0 +1,64 @@ +package com.printcalculator.service; + +import com.printcalculator.exception.VirusDetectedException; +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:true}") boolean enabled + ) { + this.enabled = enabled; + ClamavClient client = null; + try { + if (enabled) { + logger.info("Initializing ClamAV client at {}:{}", host, port); + 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> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); + logger.warn("VIRUS DETECTED: {}", viruses); + throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses); + } else { + logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result); + return true; + } + } catch (VirusDetectedException e) { + throw e; + } catch (Exception e) { + logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e); + return true; + } + } +} diff --git a/deploy/envs/dev.env b/deploy/envs/dev.env index 0364437..88f337d 100644 --- a/deploy/envs/dev.env +++ b/deploy/envs/dev.env @@ -7,4 +7,7 @@ TAG=dev BACKEND_PORT=18002 FRONTEND_PORT=18082 +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true diff --git a/deploy/envs/int.env b/deploy/envs/int.env index 79f7a35..1353b58 100644 --- a/deploy/envs/int.env +++ b/deploy/envs/int.env @@ -7,4 +7,7 @@ TAG=int BACKEND_PORT=18001 FRONTEND_PORT=18081 +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true diff --git a/deploy/envs/prod.env b/deploy/envs/prod.env index 878558b..a91bbcb 100644 --- a/deploy/envs/prod.env +++ b/deploy/envs/prod.env @@ -7,4 +7,7 @@ TAG=prod BACKEND_PORT=8000 FRONTEND_PORT=80 +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 47df56f..da3d8cc 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -10,6 +10,9 @@ services: - DB_URL=${DB_URL} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} + - CLAMAV_HOST=${CLAMAV_HOST} + - CLAMAV_PORT=${CLAMAV_PORT} + - CLAMAV_ENABLED=${CLAMAV_ENABLED} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always diff --git a/docker-compose.yml b/docker-compose.yml index faf3593..f347611 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,8 +20,11 @@ services: - MARKUP_PERCENT=20 - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles + - CLAMAV_HOST=clamav + - CLAMAV_PORT=3310 depends_on: - db + - clamav restart: unless-stopped frontend: @@ -49,5 +52,13 @@ services: - postgres_data:/var/lib/postgresql/data restart: unless-stopped + clamav: + platform: linux/amd64 + image: clamav/clamav:latest + container_name: print-calculator-clamav + ports: + - "3310:3310" + restart: unless-stopped + volumes: postgres_data: -- 2.49.1 From 8e12b3bcdfaffaf8bfccb5eca8fcc71238bf8929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 20 Feb 2026 10:32:07 +0100 Subject: [PATCH 24/72] feat(back-end): add ClamAV service remember to add env and compose.deploy on server --- .../com/printcalculator/entity/OrderItem.java | 12 +- .../features/checkout/checkout.component.html | 95 +++++++------- .../features/checkout/checkout.component.scss | 29 +++-- .../features/checkout/checkout.component.ts | 10 ++ .../stl-viewer/stl-viewer.component.ts | 120 +++++++++++++++--- frontend/src/assets/i18n/en.json | 24 ++++ frontend/src/assets/i18n/it.json | 24 ++++ 7 files changed, 238 insertions(+), 76 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index 2fa952d..287efcc 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -67,6 +67,16 @@ public class OrderItem { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; + @PrePersist + private void onCreate() { + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + if (quantity == null) { + quantity = 1; + } + } + public UUID getId() { return id; } @@ -195,4 +205,4 @@ public class OrderItem { this.createdAt = createdAt; } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 1b8168d..bc21c61 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,5 +1,5 @@
-

Checkout

+

{{ 'CHECKOUT.TITLE' | translate }}

@@ -15,23 +15,12 @@
-

Contact Information

+

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

- - -
-
- Private -
-
- Company -
-
-
- - + +
@@ -39,24 +28,37 @@
-

Billing Address

+

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

- - + +
- - - - - + +
- - - + + + +
+ + +
+
+ {{ 'CONTACT.TYPE_PRIVATE' | translate }} +
+
+ {{ 'CONTACT.TYPE_COMPANY' | translate }} +
+
+ + +
+ +
@@ -66,36 +68,39 @@
-

Shipping Address

+

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

- - + +
- - - +
+ + +
+ +
- - - + + +
- {{ isSubmitting() ? 'Processing...' : 'Place Order' }} + {{ isSubmitting() ? ('CHECKOUT.PROCESSING' | translate) : ('CHECKOUT.PLACE_ORDER' | translate) }}
@@ -106,7 +111,7 @@
-

Order Summary

+

{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}

@@ -115,7 +120,7 @@
{{ item.originalFilename }}
- Qty: {{ item.quantity }} + {{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}
@@ -130,17 +135,17 @@
- Subtotal - {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }} + {{ 'CHECKOUT.SUBTOTAL' | translate }} + {{ session.itemsTotalChf | currency:'CHF' }}
- Setup Fee - {{ session.setupCostChf | currency:'CHF' }} + {{ 'CHECKOUT.SETUP_FEE' | translate }} + {{ session.session.setupCostChf | currency:'CHF' }}
- Total - {{ session.totalPrice | currency:'CHF' }} + {{ 'CHECKOUT.TOTAL' | translate }} + {{ session.grandTotalChf | currency:'CHF' }}
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index c4e5074..f2a6741 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -72,15 +72,16 @@ } } -/* User Type Selector Styles */ +/* User Type Selector Styles - Matched with Contact Form */ .user-type-selector { display: flex; - background-color: var(--color-bg-subtle); + 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 { @@ -105,6 +106,16 @@ } } +.company-fields { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding-left: var(--space-4); + border-left: 2px solid var(--color-border); + margin-top: var(--space-4); + margin-bottom: var(--space-4); +} + .shipping-option { margin: var(--space-6) 0; } @@ -239,9 +250,8 @@ } .summary-totals { - background: var(--color-bg-subtle); - padding: var(--space-4); - border-radius: var(--radius-md); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); margin-top: var(--space-4); .total-row { @@ -249,21 +259,18 @@ justify-content: space-between; margin-bottom: var(--space-2); color: var(--color-text); + font-size: 0.95rem; &.grand-total { color: var(--color-heading); font-weight: 700; font-size: 1.25rem; - margin-top: var(--space-3); - padding-top: var(--space-3); + margin-top: var(--space-4); + padding-top: var(--space-4); border-top: 1px solid var(--color-border); margin-bottom: 0; } } - - .divider { - display: none; // Handled by border-top in grand-total - } } .actions { diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 8336061..84a5ccf 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -2,6 +2,7 @@ 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'; @@ -12,6 +13,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto imports: [ CommonModule, ReactiveFormsModule, + TranslateModule, AppInputComponent, AppButtonComponent ], @@ -43,6 +45,7 @@ export class CheckoutComponent implements OnInit { firstName: ['', Validators.required], lastName: ['', Validators.required], companyName: [''], + referencePerson: [''], addressLine1: ['', Validators.required], addressLine2: [''], zip: ['', Validators.required], @@ -54,6 +57,7 @@ export class CheckoutComponent implements OnInit { firstName: [''], lastName: [''], companyName: [''], + referencePerson: [''], addressLine1: [''], addressLine2: [''], zip: [''], @@ -74,13 +78,17 @@ export class CheckoutComponent implements OnInit { // Update validators based on type const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup; const companyControl = billingGroup.get('companyName'); + const referenceControl = billingGroup.get('referencePerson'); if (isCompany) { companyControl?.setValidators([Validators.required]); + referenceControl?.setValidators([Validators.required]); } else { companyControl?.clearValidators(); + referenceControl?.clearValidators(); } companyControl?.updateValueAndValidity(); + referenceControl?.updateValueAndValidity(); } ngOnInit(): void { @@ -147,6 +155,7 @@ export class CheckoutComponent implements OnInit { firstName: formVal.billingAddress.firstName, lastName: formVal.billingAddress.lastName, companyName: formVal.billingAddress.companyName, + contactPerson: formVal.billingAddress.referencePerson, addressLine1: formVal.billingAddress.addressLine1, addressLine2: formVal.billingAddress.addressLine2, zip: formVal.billingAddress.zip, @@ -157,6 +166,7 @@ export class CheckoutComponent implements OnInit { firstName: formVal.shippingAddress.firstName, lastName: formVal.shippingAddress.lastName, companyName: formVal.shippingAddress.companyName, + contactPerson: formVal.shippingAddress.referencePerson, addressLine1: formVal.shippingAddress.addressLine1, addressLine2: formVal.shippingAddress.addressLine2, zip: formVal.shippingAddress.zip, diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts index b9ce5cc..bdc7c60 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts @@ -25,6 +25,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private controls!: OrbitControls; private animationId: number | null = null; private currentMesh: THREE.Mesh | null = null; + private autoRotate = true; loading = false; @@ -38,14 +39,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { } if (changes['color'] && this.currentMesh && !changes['file']) { - // Update existing mesh color if only color changed - const mat = this.currentMesh.material as THREE.MeshPhongMaterial; - mat.color.set(this.color); + this.applyColorStyle(this.color); } } ngOnDestroy() { if (this.animationId) cancelAnimationFrame(this.animationId); + this.clearCurrentMesh(); + if (this.controls) this.controls.dispose(); if (this.renderer) this.renderer.dispose(); } @@ -54,28 +55,51 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { const height = this.rendererContainer.nativeElement.clientHeight; this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50 + this.scene.background = new THREE.Color(0xf4f8fc); // Lights - const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); this.scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); - directionalLight.position.set(1, 1, 1); - this.scene.add(directionalLight); + const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95); + hemiLight.position.set(0, 30, 0); + this.scene.add(hemiLight); + + const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35); + directionalLight1.position.set(6, 8, 6); + this.scene.add(directionalLight1); + + const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85); + directionalLight2.position.set(-7, 4, -5); + this.scene.add(directionalLight2); + + const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55); + directionalLight3.position.set(0, 5, -9); + this.scene.add(directionalLight3); // Camera this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); this.camera.position.z = 100; // Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.2; this.renderer.setSize(width, height); this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); // Controls this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; + this.controls.dampingFactor = 0.06; + this.controls.enablePan = false; + this.controls.minDistance = 10; + this.controls.maxDistance = 600; + this.controls.addEventListener('start', () => { + this.autoRotate = false; + }); this.animate(); @@ -95,24 +119,27 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private loadFile(file: File) { this.loading = true; + this.autoRotate = true; const reader = new FileReader(); reader.onload = (event) => { try { const loader = new STLLoader(); const geometry = loader.parse(event.target?.result as ArrayBuffer); - if (this.currentMesh) { - this.scene.remove(this.currentMesh); - this.currentMesh.geometry.dispose(); - } + this.clearCurrentMesh(); - const material = new THREE.MeshPhongMaterial({ - color: this.color, - specular: 0x111111, - shininess: 200 + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: this.color, + roughness: 0.42, + metalness: 0.05, + emissive: 0x000000, + emissiveIntensity: 0 }); this.currentMesh = new THREE.Mesh(geometry, material); + this.applyColorStyle(this.color); // Center geometry geometry.computeBoundingBox(); @@ -140,9 +167,10 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { // Calculate distance towards camera (z-axis) let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); - cameraZ *= 1.5; // Tighter zoom (reduced from 2.5) + cameraZ *= 1.72; - this.camera.position.z = cameraZ; + this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); + this.camera.lookAt(0, 0, 0); this.camera.updateProjectionMatrix(); this.controls.update(); @@ -157,9 +185,63 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private animate() { this.animationId = requestAnimationFrame(() => this.animate()); + + if (this.currentMesh && this.autoRotate) { + this.currentMesh.rotation.z += 0.0025; + } + if (this.controls) this.controls.update(); if (this.renderer && this.scene && this.camera) { this.renderer.render(this.scene, this.camera); } } + + private clearCurrentMesh() { + if (!this.currentMesh) { + return; + } + + this.scene.remove(this.currentMesh); + this.currentMesh.geometry.dispose(); + + const meshMaterial = this.currentMesh.material; + if (Array.isArray(meshMaterial)) { + meshMaterial.forEach((m) => m.dispose()); + } else { + meshMaterial.dispose(); + } + + this.currentMesh = null; + } + + private applyColorStyle(color: string) { + if (!this.currentMesh) { + return; + } + + const darkColor = this.isDarkColor(color); + const meshMaterial = this.currentMesh.material; + + if (meshMaterial instanceof THREE.MeshStandardMaterial) { + meshMaterial.color.set(color); + if (darkColor) { + meshMaterial.emissive.set(0x2a2f36); + meshMaterial.emissiveIntensity = 0.28; + meshMaterial.roughness = 0.5; + meshMaterial.metalness = 0.03; + } else { + meshMaterial.emissive.set(0x000000); + meshMaterial.emissiveIntensity = 0; + meshMaterial.roughness = 0.42; + meshMaterial.metalness = 0.05; + } + meshMaterial.needsUpdate = true; + } + } + + private isDarkColor(color: string): boolean { + const c = new THREE.Color(color); + const luminance = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + return luminance < 0.22; + } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e5d3916..0c8f67f 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -148,5 +148,29 @@ "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.", "SEND_ANOTHER": "Send Another Message" + }, + "CHECKOUT": { + "TITLE": "Checkout", + "CONTACT_INFO": "Contact Information", + "BILLING_ADDR": "Billing Address", + "SHIPPING_ADDR": "Shipping Address", + "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", + "SHIPPING_SAME": "Shipping address same as billing", + "PLACE_ORDER": "Place Order", + "PROCESSING": "Processing...", + "SUMMARY_TITLE": "Order Summary", + "SUBTOTAL": "Subtotal", + "SETUP_FEE": "Setup Fee", + "TOTAL": "Total", + "QTY": "Qty" } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index c9a7be4..245b62c 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -127,5 +127,29 @@ "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.", "SEND_ANOTHER": "Invia un altro messaggio" + }, + "CHECKOUT": { + "TITLE": "Checkout", + "CONTACT_INFO": "Informazioni di Contatto", + "BILLING_ADDR": "Indirizzo di Fatturazione", + "SHIPPING_ADDR": "Indirizzo di Spedizione", + "FIRST_NAME": "Nome", + "LAST_NAME": "Cognome", + "EMAIL": "Email", + "PHONE": "Telefono", + "COMPANY_NAME": "Nome Azienda", + "ADDRESS_1": "Indirizzo (Via e numero)", + "ADDRESS_2": "Informazioni aggiuntive (opzionale)", + "ZIP": "CAP", + "CITY": "Città", + "COUNTRY": "Paese", + "SHIPPING_SAME": "L'indirizzo di spedizione è lo stesso di quello di fatturazione", + "PLACE_ORDER": "Invia Ordine", + "PROCESSING": "Elaborazione...", + "SUMMARY_TITLE": "Riepilogo Ordine", + "SUBTOTAL": "Subtotale", + "SETUP_FEE": "Costo di Avvio", + "TOTAL": "Totale", + "QTY": "Qtà" } } -- 2.49.1 From ccc53b7d4f5b599e47971a09c4c9b12ecba642ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 20 Feb 2026 14:54:28 +0100 Subject: [PATCH 25/72] feat(back-end): bill and qr --- backend/build.gradle | 10 +- .../controller/OrderController.java | 357 +++++------ .../com/printcalculator/dto/OrderDto.java | 74 +++ .../com/printcalculator/dto/OrderItemDto.java | 44 ++ .../exception/StorageException.java | 12 + .../repository/OrderItemRepository.java | 2 + .../service/FileSystemStorageService.java | 94 +++ .../service/InvoicePdfRenderingService.java | 48 ++ .../printcalculator/service/OrderService.java | 300 +++++++++ .../service/QrBillService.java | 68 +++ .../service/StorageService.java | 14 + .../src/main/resources/application.properties | 5 + .../src/main/resources/templates/invoice.html | 249 ++++++++ frontend/package-lock.json | 568 ++++++++++-------- .../services/quote-estimator.service.ts | 17 + .../features/checkout/checkout.component.html | 211 +++---- .../features/checkout/checkout.component.scss | 188 +++--- .../features/checkout/checkout.component.ts | 13 +- .../contact-form/contact-form.component.html | 8 +- .../features/payment/payment.component.html | 128 +++- .../features/payment/payment.component.scss | 227 ++++++- .../app/features/payment/payment.component.ts | 67 ++- frontend/src/assets/i18n/en.json | 22 +- frontend/src/assets/i18n/it.json | 22 +- 24 files changed, 2034 insertions(+), 714 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/OrderDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/OrderItemDto.java create mode 100644 backend/src/main/java/com/printcalculator/exception/StorageException.java create mode 100644 backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java create mode 100644 backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java create mode 100644 backend/src/main/java/com/printcalculator/service/OrderService.java create mode 100644 backend/src/main/java/com/printcalculator/service/QrBillService.java create mode 100644 backend/src/main/java/com/printcalculator/service/StorageService.java create mode 100644 backend/src/main/resources/templates/invoice.html diff --git a/backend/build.gradle b/backend/build.gradle index 72fed37..b15d8ef 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,13 +25,21 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'xyz.capybara:clamav-client:2.1.2' runtimeOnly 'org.postgresql:postgresql' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly 'org.projectlombok:lombok' - implementation 'xyz.capybara:clamav-client:2.1.2' 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') { diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 2d4e637..3c70513 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -1,13 +1,17 @@ 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 com.fasterxml.jackson.annotation.JsonProperty; import java.io.IOException; import java.math.BigDecimal; @@ -15,209 +19,61 @@ 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.Optional; +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 com.printcalculator.service.ClamAVService clamAVService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; - // TODO: Inject Storage Service or use a base path property - private static final String STORAGE_ROOT = "storage_orders"; - public OrderController(OrderRepository orderRepo, + public OrderController(OrderService orderService, + OrderRepository orderRepo, OrderItemRepository orderItemRepo, QuoteSessionRepository quoteSessionRepo, QuoteLineItemRepository quoteLineItemRepo, CustomerRepository customerRepo, - com.printcalculator.service.ClamAVService clamAVService) { + 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.clamAVService = clamAVService; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; } // 1. Create Order from Quote @PostMapping("/from-quote/{quoteSessionId}") @Transactional - public ResponseEntity createOrderFromQuote( + public ResponseEntity createOrderFromQuote( @PathVariable UUID quoteSessionId, @RequestBody com.printcalculator.dto.CreateOrderRequest request ) { - // 1. Fetch Quote Session - QuoteSession session = quoteSessionRepo.findById(quoteSessionId) - .orElseThrow(() -> new RuntimeException("Quote Session not found")); - - if (!"ACTIVE".equals(session.getStatus())) { - // Allow converting only active sessions? Or check if not already converted? - // checking convertedOrderId might be better - } - if (session.getConvertedOrderId() != null) { - return ResponseEntity.badRequest().body(null); // Already converted - } - - // 2. Handle Customer (Find or Create) - Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail()) - .orElseGet(() -> { - Customer newC = new Customer(); - newC.setEmail(request.getCustomer().getEmail()); - newC.setCreatedAt(OffsetDateTime.now()); - return customerRepo.save(newC); - }); - // Update customer details? - customer.setPhone(request.getCustomer().getPhone()); - customer.setCustomerType(request.getCustomer().getCustomerType()); - customer.setUpdatedAt(OffsetDateTime.now()); - customerRepo.save(customer); - - // 3. Create Order - 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"); - // Initialize all NOT NULL monetary fields before first persist. - order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); - order.setShippingCostChf(BigDecimal.ZERO); - order.setDiscountChf(BigDecimal.ZERO); - order.setSubtotalChf(BigDecimal.ZERO); - order.setTotalChf(BigDecimal.ZERO); - - // Billing - 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"); - } - - // Shipping - 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 { - // Copy billing to shipping? Or leave empty and rely on flag? - // Usually explicit copy is safer for queries - 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()); - } - - // Financials from Session (Assuming mocked/calculated in session) - // We re-calculate totals from line items to be safe - List quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); - - BigDecimal subtotal = BigDecimal.ZERO; - - // Save Order first to get ID - order = orderRepo.save(order); - - // 4. Create Order Items - 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()); // Or per item if supported - - // Pricing - oItem.setUnitPriceChf(qItem.getUnitPriceChf()); - oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); - oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); - oItem.setMaterialGrams(qItem.getMaterialGrams()); - - // File Handling Check - // "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" - UUID fileUuid = UUID.randomUUID(); - String ext = getExtension(qItem.getOriginalFilename()); - String storedFilename = fileUuid.toString() + "." + ext; - - oItem.setStoredFilename(storedFilename); - oItem.setStoredRelativePath("PENDING"); // Placeholder - oItem.setMimeType("application/octet-stream"); // specific type if known - - oItem = orderItemRepo.save(oItem); - - // Update Path now that we have ID - String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; - oItem.setStoredRelativePath(relativePath); - - // COPY FILE from Quote to Order - if (qItem.getStoredPath() != null) { - try { - Path sourcePath = Paths.get(qItem.getStoredPath()); - if (Files.exists(sourcePath)) { - Path targetPath = Paths.get(STORAGE_ROOT, relativePath); - Files.createDirectories(targetPath.getParent()); - Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - - oItem.setFileSizeBytes(Files.size(targetPath)); - } - } catch (IOException e) { - e.printStackTrace(); // Log error but allow order creation? Or fail? - // Ideally fail or mark as error - } - } - - orderItemRepo.save(oItem); - - subtotal = subtotal.add(oItem.getLineTotalChf()); - } - - // Update Order Totals - order.setSubtotalChf(subtotal); - order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); - order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? - // TODO: Calc implementation for shipping - - BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); - order.setTotalChf(total); - - // Link session - session.setConvertedOrderId(order.getId()); - session.setStatus("CONVERTED"); // or CLOSED - quoteSessionRepo.save(session); - - return ResponseEntity.ok(orderRepo.save(order)); + Order order = orderService.createOrderFromQuote(quoteSessionId, request); + List items = orderItemRepo.findByOrder_Id(order.getId()); + return ResponseEntity.ok(convertToDto(order, items)); } - // 2. Upload file for Order Item @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity uploadOrderItemFile( @@ -232,42 +88,103 @@ public class OrderController { if (!item.getOrder().getId().equals(orderId)) { return ResponseEntity.badRequest().build(); } - - // Scan for virus - clamAVService.scan(file.getInputStream()); - // Ensure path logic String relativePath = item.getStoredRelativePath(); if (relativePath == null || relativePath.equals("PENDING")) { - // Should verify consistency - // If we used the logic above, it should have a path. - // If it's "PENDING", regen it. String ext = getExtension(file.getOriginalFilename()); String storedFilename = UUID.randomUUID().toString() + "." + ext; relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename; item.setStoredRelativePath(relativePath); item.setStoredFilename(storedFilename); - // Update item } - // Save file to disk - Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); - Files.createDirectories(absolutePath.getParent()); - - if (Files.exists(absolutePath)) { - Files.delete(absolutePath); // Overwrite? - } - - Files.copy(file.getInputStream(), absolutePath); - + storageService.store(file, Paths.get(relativePath)); item.setFileSizeBytes(file.getSize()); item.setMimeType(file.getContentType()); - // Calculate SHA256? (Optional) - orderItemRepo.save(item); return ResponseEntity.ok().build(); } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return orderRepo.findById(orderId) + .map(o -> { + List items = orderItemRepo.findByOrder_Id(o.getId()); + return ResponseEntity.ok(convertToDto(o, items)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/{orderId}/invoice") + public ResponseEntity getInvoice(@PathVariable UUID orderId) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found")); + + List items = orderItemRepo.findByOrder_Id(orderId); + + Map 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> invoiceLineItems = items.stream().map(i -> { + Map 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 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 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(" 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 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; + } + } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java new file mode 100644 index 0000000..982d9c3 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -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 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 getItems() { return items; } + public void setItems(List items) { this.items = items; } +} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java new file mode 100644 index 0000000..d31d208 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -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; } +} diff --git a/backend/src/main/java/com/printcalculator/exception/StorageException.java b/backend/src/main/java/com/printcalculator/exception/StorageException.java new file mode 100644 index 0000000..0a0da37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/StorageException.java @@ -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); + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java index b43a305..0068809 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -3,7 +3,9 @@ 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 { + List findByOrder_Id(UUID orderId); } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java new file mode 100644 index 0000000..7db4aa8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java @@ -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); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java new file mode 100644 index 0000000..a21e59f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -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 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); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java new file mode 100644 index 0000000..02d3289 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -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 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 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 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(" 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> invoiceLineItems = items.stream().map(i -> { + Map 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 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 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"; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/QrBillService.java new file mode 100644 index 0000000..093739f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/QrBillService.java @@ -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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/StorageService.java b/backend/src/main/java/com/printcalculator/service/StorageService.java new file mode 100644 index 0000000..5fe2321 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/StorageService.java @@ -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; +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index fc32567..3d70ca6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -18,3 +18,8 @@ profiles.root=${PROFILES_DIR:profiles} # File Upload Limits spring.servlet.multipart.max-file-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} diff --git a/backend/src/main/resources/templates/invoice.html b/backend/src/main/resources/templates/invoice.html new file mode 100644 index 0000000..657d5d7 --- /dev/null +++ b/backend/src/main/resources/templates/invoice.html @@ -0,0 +1,249 @@ + + + + + + + +
+ + + + + + +
+
Nome Cognome
+
Via Esempio 12
+
6500 Bellinzona, CH
+
email@example.com
+
+
Fattura
+
Numero: 2026-000123
+
Data: 2026-02-13
+
Scadenza: 2026-02-20
+
+ +
Fatturare a
+
+
+
Cliente SA
+
Via Cliente 7
+
8000 Zürich, CH
+
+
+ + + + + + + + + + + + + + + + + + +
DescrizioneQtàPrezzoTotale
Stampa 3D pezzo X1CHF 10.00CHF 10.00
+ + + + + + + + + + +
SubtotaleCHF 10.00
TotaleCHF 10.00
+ +
+ Pagamento entro 7 giorni. Grazie. +
+ +
+ +
+
+
+
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3aaedd..1a67380 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,13 +57,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.19.tgz", - "integrity": "sha512-iexYDIYpGAeAU7T60bGcfrGwtq1bxpZixYxWuHYiaD1b5baQgNSfd1isGEOh37GgDNsf4In9i2LOLPm0wBdtgQ==", + "version": "0.1902.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.20.tgz", + "integrity": "sha512-tEM8PX9RTIvgEPJH/9nDaGlhbjZf9BBFS2FXKuOwKB+NFvfZuuDpPH7CzJKyyvkQLPtoNh2Y9C92m2f+RXsBmQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.20", "rxjs": "7.8.1" }, "engines": { @@ -83,17 +83,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.19.tgz", - "integrity": "sha512-uIxi6Vzss6+ycljVhkyPUPWa20w8qxJL9lEn0h6+sX/fhM8Djt0FHIuTQjoX58EoMaQ/1jrXaRaGimkbaFcG9A==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.20.tgz", + "integrity": "sha512-m7J+k0lJEFvr6STGUQROx6TyoGn0WQsQiooO8WTkM8QUWKxSUmq4WImlPSq6y+thc+Jzx1EBw3yn73+phNIZag==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.19", - "@angular-devkit/build-webpack": "0.1902.19", - "@angular-devkit/core": "19.2.19", - "@angular/build": "19.2.19", + "@angular-devkit/architect": "0.1902.20", + "@angular-devkit/build-webpack": "0.1902.20", + "@angular-devkit/core": "19.2.20", + "@angular/build": "19.2.20", "@babel/core": "7.26.10", "@babel/generator": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", @@ -104,7 +104,7 @@ "@babel/preset-env": "7.26.9", "@babel/runtime": "7.26.10", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.19", + "@ngtools/webpack": "19.2.20", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -139,7 +139,7 @@ "terser": "5.39.0", "tree-kill": "1.2.2", "tslib": "2.8.1", - "webpack": "5.98.0", + "webpack": "5.105.0", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.2.2", "webpack-merge": "6.0.1", @@ -158,7 +158,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.19", + "@angular/ssr": "^19.2.20", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -219,13 +219,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.19.tgz", - "integrity": "sha512-x2tlGg5CsUveFzuRuqeHknSbGirSAoRynEh+KqPRGK0G3WpMViW/M8SuVurecasegfIrDWtYZ4FnVxKqNbKwXQ==", + "version": "0.1902.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.20.tgz", + "integrity": "sha512-T8RLKZOR0+l3FBMBTUQk83I/Dr5RpNPCOE6tWqGjAMRPKoL1m5BbqhkQ7ygnyd8/ZRz/x1RUVM08l0AeuzWUmA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.19", + "@angular-devkit/architect": "0.1902.20", "rxjs": "7.8.1" }, "engines": { @@ -249,9 +249,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.20.tgz", + "integrity": "sha512-4AAmHlv+H1/2Nmsp6QsX8YQxjC/v5QAzc+76He7K/x3iIuLCntQE2BYxonSZMiQ3M8gc/yxTfyZoPYjSDDvWMA==", "dev": true, "license": "MIT", "dependencies": { @@ -287,13 +287,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.19.tgz", - "integrity": "sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.20.tgz", + "integrity": "sha512-o2eexF1fLZU93V3utiQLNgyNaGvFhDqpITNQcI1qzv2ZkvFHg9WZjFtZKtm805JAE/DND8oAJ1p+BoxU++Qg8g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.20", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -316,14 +316,14 @@ } }, "node_modules/@angular/build": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.19.tgz", - "integrity": "sha512-SFzQ1bRkNFiOVu+aaz+9INmts7tDUrsHLEr9HmARXr9qk5UmR8prlw39p2u+Bvi6/lCiJ18TZMQQl9mGyr63lg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.20.tgz", + "integrity": "sha512-8bQ1afN8AJ6N9lJJgxYF08M0gp4R/4SIedSJfSLohscgHumYJ1mITEygoB1JK5O9CEKlr4YyLYfgay8xr92wbQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.19", + "@angular-devkit/architect": "0.1902.20", "@babel/core": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -363,7 +363,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.19", + "@angular/ssr": "^19.2.20", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^19.0.0 || ^19.2.0-next.0", @@ -417,18 +417,18 @@ } }, "node_modules/@angular/cli": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.19.tgz", - "integrity": "sha512-e9tAzFNOL4mMWfMnpC9Up83OCTOp2siIj8W41FCp8jfoEnw79AXDDLh3d70kOayiObchksTJVShslTogLUyhMw==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.20.tgz", + "integrity": "sha512-3vw49xDGqOi63FES/6D+Lw0Sl42FSZKowUxBMY0CnXD8L93Qwvcf4ASFmUoNJRSTOJuuife1+55vY62cpOWBdg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.19", - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/architect": "0.1902.20", + "@angular-devkit/core": "19.2.20", + "@angular-devkit/schematics": "19.2.20", "@inquirer/prompts": "7.3.2", "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.19", + "@schematics/angular": "19.2.20", "@yarnpkg/lockfile": "1.1.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", @@ -3341,9 +3341,9 @@ } }, "node_modules/@jsonjoy.com/buffers": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.65.0.tgz", - "integrity": "sha512-eBrIXd0/Ld3p9lpDDlMaMn6IEfWqtHMD+z61u0JrIiPzsV1r7m6xDZFRxJyvIFTEO+SWdYF9EiQbXZGd8BzPfA==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3549,9 +3549,9 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.65.0.tgz", - "integrity": "sha512-Xrh7Fm/M0QAYpekSgmskdZYnFdSGnsxJ/tHaolA4bNwWdG9i65S8m83Meh7FOxyJyQAdo4d4J97NOomBLEfkDQ==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3566,9 +3566,9 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.65.0.tgz", - "integrity": "sha512-7MXcRYe7n3BG+fo3jicvjB0+6ypl2Y/bQp79Sp7KeSiiCgLqw4Oled6chVv07/xLVTdo3qa1CD0VCCnPaw+RGA==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3583,17 +3583,17 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.65.0.tgz", - "integrity": "sha512-e0SG/6qUCnVhHa0rjDJHgnXnbsacooHVqQHxspjvlYQSkHm+66wkHw6Gql+3u/WxI/b1VsOdUi0M+fOtkgKGdQ==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/base64": "17.65.0", - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0", - "@jsonjoy.com/json-pointer": "17.65.0", - "@jsonjoy.com/util": "17.65.0", + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" @@ -3610,13 +3610,13 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.65.0.tgz", - "integrity": "sha512-uhTe+XhlIZpWOxgPcnO+iSCDgKKBpwkDVTyYiXX9VayGV8HSFVJM67M6pUE71zdnXF1W0Da21AvnhlmdwYPpow==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/util": "17.65.0" + "@jsonjoy.com/util": "17.67.0" }, "engines": { "node": ">=10.0" @@ -3630,14 +3630,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.65.0.tgz", - "integrity": "sha512-cWiEHZccQORf96q2y6zU3wDeIVPeidmGqd9cNKJRYoVHTV0S1eHPy5JTbHpMnGfDvtvujQwQozOqgO9ABu6h0w==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0" + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" }, "engines": { "node": ">=10.0" @@ -4291,9 +4291,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.19.tgz", - "integrity": "sha512-R9aeTrOBiRVl8I698JWPniUAAEpSvzc8SUGWSM5UXWMcHnWqd92cOnJJ1aXDGJZKXrbhMhCBx9Dglmcks5IDpg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.20.tgz", + "integrity": "sha512-nuCjcxLmFrn0s53G67V5R19mUpYjewZBLz6Wrg7BtJkjq08xfO0QgaJg3e6wzEmj1AclH7eMKRnuQhm5otyutg==", "dev": true, "license": "MIT", "engines": { @@ -4489,9 +4489,9 @@ } }, "node_modules/@npmcli/package-json": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", - "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", + "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", "dev": true, "license": "ISC", "dependencies": { @@ -5131,9 +5131,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -5145,9 +5145,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -5187,9 +5187,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -5201,9 +5201,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -5229,9 +5229,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -5285,9 +5285,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -5299,9 +5299,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -5341,9 +5341,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -5369,14 +5369,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.19.tgz", - "integrity": "sha512-6/0pvbPCY4UHeB4lnM/5r250QX5gcLgOYbR5FdhFu+22mOPHfWpRc5tNuY9kCephDHzAHjo6fTW1vefOOmA4jw==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.20.tgz", + "integrity": "sha512-xDrYxZvk9dGA2eVqufqLYmVSMSXxVtv30pBHGGU/2xr4QzHzdmMHflk4It8eh4WMNLhn7kqnzMREwtNI3eW/Gw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/core": "19.2.20", + "@angular-devkit/schematics": "19.2.20", "jsonc-parser": "3.3.1" }, "engines": { @@ -6053,9 +6053,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -6065,6 +6065,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/adjust-sourcemap-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", @@ -6407,6 +6420,19 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -6554,9 +6580,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -6574,10 +6600,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -6764,9 +6791,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "dev": true, "funding": [ { @@ -7469,9 +7496,9 @@ } }, "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -7685,9 +7712,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -7793,14 +7820,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -7910,9 +7937,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -9414,9 +9441,9 @@ "license": "MIT" }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -10046,9 +10073,9 @@ } }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.0.tgz", + "integrity": "sha512-u+9asUHMJ99lA15VRMXw5XKfySFR9dGXwgsgS14YTbUq3GITP58mIM32At90P5fZ+MUId5Yw+IwI/yKub7jnCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10270,13 +10297,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -11070,9 +11101,9 @@ } }, "node_modules/node-gyp": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", - "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11137,9 +11168,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -12090,9 +12121,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12647,9 +12678,9 @@ "optional": true }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -13602,13 +13633,17 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -13658,9 +13693,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -13806,15 +13841,15 @@ "license": "0BSD" }, "node_modules/tuf-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", - "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", + "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", "dev": true, "license": "MIT", "dependencies": { "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -14006,9 +14041,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -14170,9 +14205,9 @@ } }, "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -14184,9 +14219,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -14198,9 +14233,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -14212,9 +14247,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -14226,9 +14261,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -14240,9 +14275,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -14254,9 +14289,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -14268,9 +14303,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -14282,9 +14317,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -14296,9 +14331,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -14310,9 +14345,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -14324,9 +14359,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -14338,9 +14373,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -14352,9 +14387,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -14366,9 +14401,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -14380,9 +14415,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -14394,9 +14429,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -14444,9 +14479,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -14460,31 +14495,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -14541,35 +14576,37 @@ "optional": true }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -14802,9 +14839,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { @@ -14833,6 +14870,13 @@ } } }, + "node_modules/webpack/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -14840,6 +14884,20 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 9d8668d..55fa6ae 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -144,6 +144,23 @@ export class QuoteEstimatorService { 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 { + 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 { + 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 { console.log('QuoteEstimatorService: Calculating quote...', request); diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index bc21c61..d3972a3 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,120 +1,122 @@
-

{{ 'CHECKOUT.TITLE' | translate }}

+
+

{{ 'CHECKOUT.TITLE' | translate }}

+
-
- - -
- -
- {{ error }} -
+
+
+ + +
+ +
+ {{ error }} +
-
- - -
-
-

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

-
-
+ + + + +
+

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

+
-
-
+ - -
-
-

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

-
-
-
- - + + +
+

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

- - - - -
- - - -
- - -
-
- {{ 'CONTACT.TYPE_PRIVATE' | translate }} +
+ + +
+ +
-
- {{ 'CONTACT.TYPE_COMPANY' | translate }} + + +
+ + +
+ + +
+
+ {{ 'CONTACT.TYPE_PRIVATE' | translate }} +
+
+ {{ 'CONTACT.TYPE_COMPANY' | translate }} +
+
+ + + + +
+ + +
+ - -
- - -
+ +
+
-
- -
- -
+ + +
+

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

+
+
+
+ + +
+ +
+ + +
- -
-
-

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

+ + +
+ + + +
+
+ + +
+ + {{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }} +
-
-
- - -
- -
- - -
- - -
- - - -
+ +
+ + +
+ +
+

{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}

-
- -
- - {{ isSubmitting() ? ('CHECKOUT.PROCESSING' | translate) : ('CHECKOUT.PLACE_ORDER' | translate) }} - -
- - -
- - -
-
-
-

{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}

-
- -
+
@@ -142,15 +144,18 @@ {{ 'CHECKOUT.SETUP_FEE' | translate }} {{ session.session.setupCostChf | currency:'CHF' }}
-
-
+
+ {{ 'CHECKOUT.SHIPPING' | translate }} + {{ 9.00 | currency:'CHF' }} +
+
{{ 'CHECKOUT.TOTAL' | translate }} - {{ session.grandTotalChf | currency:'CHF' }} + {{ (session.grandTotalChf + 9.00) | currency:'CHF' }}
-
+
-
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index f2a6741..edd1bfd 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -1,84 +1,76 @@ -.checkout-page { - padding: 3rem 1rem; - max-width: 1200px; - margin: 0 auto; +.hero { + padding: var(--space-8) 0; + text-align: center; + + .section-title { + font-size: 2.5rem; + margin-bottom: var(--space-2); + } } .checkout-layout { display: grid; - grid-template-columns: 1fr 380px; + grid-template-columns: 1fr 420px; gap: var(--space-8); align-items: start; + margin-bottom: var(--space-12); - @media (max-width: 900px) { + @media (max-width: 1024px) { grid-template-columns: 1fr; - gap: var(--space-6); + gap: var(--space-8); } } -.section-title { - font-size: 2rem; - font-weight: 700; +.card-header-simple { margin-bottom: var(--space-6); - color: var(--color-heading); -} - -.form-card { - margin-bottom: var(--space-6); - background: var(--color-bg-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - overflow: hidden; - - .card-header { - padding: var(--space-4) var(--space-6); - border-bottom: 1px solid var(--color-border); - background: var(--color-bg-subtle); - - h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--color-heading); - margin: 0; - } - } - - .card-content { - padding: 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: 1fr 2fr 1fr; + grid-template-columns: 1.5fr 2fr 1fr; gap: var(--space-4); - } - - app-input { - flex: 1; - width: 100%; - } - - @media (max-width: 600px) { - flex-direction: column; - &.three-cols { + + @media (max-width: 768px) { grid-template-columns: 1fr; } } + + app-input { + width: 100%; + } } -/* User Type Selector Styles - Matched with Contact Form */ +/* 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); + margin: var(--space-6) 0; gap: 4px; width: 100%; max-width: 400px; @@ -112,12 +104,14 @@ gap: var(--space-4); padding-left: var(--space-4); border-left: 2px solid var(--color-border); - margin-top: var(--space-4); 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 */ @@ -125,9 +119,10 @@ display: flex; align-items: center; position: relative; - padding-left: 30px; + padding-left: 36px; cursor: pointer; font-size: 1rem; + font-weight: 500; user-select: none; color: var(--color-text); @@ -153,10 +148,10 @@ top: 50%; left: 0; transform: translateY(-50%); - height: 20px; - width: 20px; - background-color: var(--color-bg-surface); - border: 1px solid var(--color-border); + 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; @@ -164,12 +159,12 @@ content: ""; position: absolute; display: none; - left: 6px; - top: 2px; + left: 7px; + top: 3px; width: 6px; height: 12px; - border: solid white; - border-width: 0 2px 2px 0; + border: solid #000; + border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); } } @@ -179,40 +174,48 @@ } } - .checkout-summary-section { position: relative; } .sticky-card { position: sticky; - top: 0; - /* Inherits styles from .form-card */ + top: var(--space-6); } .summary-items { margin-bottom: var(--space-6); - max-height: 400px; + max-height: 450px; overflow-y: auto; + padding-right: var(--space-2); + padding-top: 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-3) 0; + padding: var(--space-4) 0; border-bottom: 1px solid var(--color-border); - &:last-child { - border-bottom: none; - } + &:first-child { padding-top: 0; } + &:last-child { border-bottom: none; } .item-details { flex: 1; .item-name { display: block; - font-weight: 500; + font-weight: 600; + font-size: 0.95rem; margin-bottom: var(--space-1); word-break: break-all; color: var(--color-text); @@ -226,8 +229,8 @@ color: var(--color-text-muted); .color-dot { - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; display: inline-block; border: 1px solid var(--color-border); @@ -245,55 +248,52 @@ font-weight: 600; margin-left: var(--space-3); white-space: nowrap; - color: var(--color-heading); + color: var(--color-text); } } .summary-totals { - padding-top: var(--space-4); - border-top: 1px solid var(--color-border); - margin-top: var(--space-4); + background: var(--color-neutral-100); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-top: var(--space-6); .total-row { display: flex; justify-content: space-between; margin-bottom: var(--space-2); - color: var(--color-text); font-size: 0.95rem; + color: var(--color-text); + } - &.grand-total { - color: var(--color-heading); - font-weight: 700; - font-size: 1.25rem; - margin-top: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--color-border); - margin-bottom: 0; - } + .grand-total { + 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-6); - display: flex; - justify-content: flex-end; + margin-top: var(--space-8); app-button { width: 100%; - - @media (min-width: 900px) { - width: auto; - min-width: 200px; - } } } .error-message { - color: var(--color-danger); - background: var(--color-danger-subtle); + color: var(--color-error); + background: #fef2f2; padding: var(--space-4); border-radius: var(--radius-md); margin-bottom: var(--space-6); - border: 1px solid var(--color-danger); + border: 1px solid #fee2e2; + font-weight: 500; } +.mb-6 { margin-bottom: var(--space-6); } diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 84a5ccf..78a2aa2 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -6,6 +6,7 @@ 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', @@ -15,7 +16,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto ReactiveFormsModule, TranslateModule, AppInputComponent, - AppButtonComponent + AppButtonComponent, + AppCardComponent ], templateUrl: './checkout.component.html', styleUrls: ['./checkout.component.scss'] @@ -75,20 +77,27 @@ export class CheckoutComponent implements OnInit { 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'); const referenceControl = billingGroup.get('referencePerson'); + const firstNameControl = billingGroup.get('firstName'); + const lastNameControl = billingGroup.get('lastName'); if (isCompany) { companyControl?.setValidators([Validators.required]); referenceControl?.setValidators([Validators.required]); + firstNameControl?.clearValidators(); + lastNameControl?.clearValidators(); } else { companyControl?.clearValidators(); referenceControl?.clearValidators(); + firstNameControl?.setValidators([Validators.required]); + lastNameControl?.setValidators([Validators.required]); } companyControl?.updateValueAndValidity(); referenceControl?.updateValueAndValidity(); + firstNameControl?.updateValueAndValidity(); + lastNameControl?.updateValueAndValidity(); } ngOnInit(): void { diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html index b0f7bed..c7e33a1 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html @@ -37,7 +37,7 @@
- +
@@ -47,10 +47,10 @@

{{ 'CONTACT.UPLOAD_HINT' | translate }}

- -
-

{{ 'CONTACT.DROP_FILES' | translate }}

diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html index 733e839..a86265b 100644 --- a/frontend/src/app/features/payment/payment.component.html +++ b/frontend/src/app/features/payment/payment.component.html @@ -1,21 +1,109 @@ -
- - - payment - Payment Integration - Order #{{ orderId }} - - -
-

Coming Soon

-

The online payment system is currently under development.

-

Your order has been saved. Please contact us to arrange payment.

-
-
- - - -
+
+

{{ 'PAYMENT.TITLE' | translate }}

+

{{ 'CHECKOUT.SUBTITLE' | translate }}

+
+ +
+
+
+ +
+

{{ 'PAYMENT.METHOD' | translate }}

+
+ +
+
+
+ TWINT +
+
+ QR Bill / Bank Transfer +
+
+
+ +
+
+

{{ 'PAYMENT.TWINT_TITLE' | translate }}

+
+
+
+ QR CODE +
+

{{ 'PAYMENT.TWINT_DESC' | translate }}

+

{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}

+
+
+ +
+
+

{{ 'PAYMENT.BANK_TITLE' | translate }}

+
+
+

{{ 'PAYMENT.BANK_OWNER' | translate }}: 3D Fab Switzerland

+

{{ 'PAYMENT.BANK_IBAN' | translate }}: CH98 0000 0000 0000 0000 0

+

{{ 'PAYMENT.BANK_REF' | translate }}: {{ o.id }}

+ +
+ + {{ 'PAYMENT.DOWNLOAD_QR' | translate }} + +
+
+
+ +
+ + {{ 'PAYMENT.CONFIRM' | translate }} + +
+
+
+ +
+ +
+

{{ 'PAYMENT.SUMMARY_TITLE' | translate }}

+

#{{ o.id.substring(0, 8) }}

+
+ +
+
+ {{ 'PAYMENT.SUBTOTAL' | translate }} + {{ o.subtotalChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SHIPPING' | translate }} + {{ o.shippingCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SETUP_FEE' | translate }} + {{ o.setupCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.TOTAL' | translate }} + {{ o.totalChf | currency:'CHF' }} +
+
+
+
+
+ +
+ +

{{ 'PAYMENT.LOADING' | translate }}

+
+
+ +
+ +

{{ error() }}

+
+
diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss index d4475db..4d3ba7f 100644 --- a/frontend/src/app/features/payment/payment.component.scss +++ b/frontend/src/app/features/payment/payment.component.scss @@ -1,35 +1,202 @@ -.payment-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 80vh; - padding: 2rem; - background-color: #f5f5f5; -} - -.payment-card { - max-width: 500px; - width: 100%; -} - -.coming-soon { +.hero { + padding: var(--space-12) 0 var(--space-8); text-align: center; - padding: 2rem 0; - - h3 { - margin-bottom: 1rem; - color: #555; - } - - p { - color: #777; - margin-bottom: 0.5rem; + + h1 { + font-size: 2.5rem; + margin-bottom: var(--space-2); } } -mat-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: #3f51b5; +.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; } diff --git a/frontend/src/app/features/payment/payment.component.ts b/frontend/src/app/features/payment/payment.component.ts index 671a36e..520e486 100644 --- a/frontend/src/app/features/payment/payment.component.ts +++ b/frontend/src/app/features/payment/payment.component.ts @@ -1,34 +1,75 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatIconModule } from '@angular/material/icon'; +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, MatButtonModule, MatCardModule, MatIconModule], + imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule], templateUrl: './payment.component.html', styleUrl: './payment.component.scss' }) export class PaymentComponent implements OnInit { - orderId: string | null = null; + private route = inject(ActivatedRoute); + private router = inject(Router); + private quoteService = inject(QuoteEstimatorService); - constructor( - private route: ActivatedRoute, - private router: Router - ) {} + orderId: string | null = null; + selectedPaymentMethod: 'twint' | 'bill' | null = null; + order = signal(null); + loading = signal(true); + error = signal(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.'); - // Here you would call the backend to mark as paid if we had that endpoint ready - // For now, redirect home or show success this.router.navigate(['/']); } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 0c8f67f..23ce97f 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -151,6 +151,7 @@ }, "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", @@ -171,6 +172,25 @@ "SUBTOTAL": "Subtotal", "SETUP_FEE": "Setup Fee", "TOTAL": "Total", - "QTY": "Qty" + "QTY": "Qty", + "SHIPPING": "Shipping" + }, + "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..." } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 245b62c..70ce4f9 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -130,6 +130,7 @@ }, "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", @@ -150,6 +151,25 @@ "SUBTOTAL": "Subtotale", "SETUP_FEE": "Costo di Avvio", "TOTAL": "Totale", - "QTY": "Qtà" + "QTY": "Qtà", + "SHIPPING": "Spedizione" + }, + "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..." } } -- 2.49.1 From 15d5d31d068e26f657a75be218fb09b6fc9b6ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 20 Feb 2026 17:09:42 +0100 Subject: [PATCH 26/72] feat(back-end, front-enc): twint payment --- .../controller/OrderController.java | 53 +++++++- .../service/TwintPaymentService.java | 66 ++++++++++ .../src/main/resources/application.properties | 3 + docker-compose.yml | 3 + frontend/src/app/app.routes.ts | 4 + .../calculator/calculator-page.component.html | 4 +- .../quote-result/quote-result.component.html | 4 +- .../upload-form/upload-form.component.html | 8 +- .../user-details/user-details.component.html | 28 ++--- .../services/quote-estimator.service.ts | 7 ++ .../features/checkout/checkout.component.html | 10 +- .../contact-form/contact-form.component.html | 4 +- .../contact/contact-page.component.html | 2 +- .../src/app/features/home/home.component.html | 98 +++++++-------- .../order-confirmed.component.html | 25 ++++ .../order-confirmed.component.scss | 62 ++++++++++ .../order-confirmed.component.ts | 28 +++++ .../features/payment/payment.component.html | 22 +++- .../features/payment/payment.component.scss | 36 ++++-- .../app/features/payment/payment.component.ts | 51 +++++++- .../shop/product-detail.component.html | 2 +- frontend/src/assets/i18n/en.json | 13 ++ frontend/src/assets/i18n/it.json | 117 +++++++++++++++++- 23 files changed, 543 insertions(+), 107 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/service/TwintPaymentService.java create mode 100644 frontend/src/app/features/order-confirmed/order-confirmed.component.html create mode 100644 frontend/src/app/features/order-confirmed/order-confirmed.component.scss create mode 100644 frontend/src/app/features/order-confirmed/order-confirmed.component.ts diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 3c70513..272b41b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -7,6 +7,7 @@ import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; import com.printcalculator.service.QrBillService; import com.printcalculator.service.StorageService; +import com.printcalculator.service.TwintPaymentService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -24,7 +25,9 @@ import java.util.List; import java.util.UUID; import java.util.Map; import java.util.HashMap; +import java.util.Base64; import java.util.stream.Collectors; +import java.net.URI; @RestController @RequestMapping("/api/orders") @@ -39,6 +42,7 @@ public class OrderController { private final StorageService storageService; private final InvoicePdfRenderingService invoiceService; private final QrBillService qrBillService; + private final TwintPaymentService twintPaymentService; public OrderController(OrderService orderService, @@ -49,7 +53,8 @@ public class OrderController { CustomerRepository customerRepo, StorageService storageService, InvoicePdfRenderingService invoiceService, - QrBillService qrBillService) { + QrBillService qrBillService, + TwintPaymentService twintPaymentService) { this.orderService = orderService; this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; @@ -59,6 +64,7 @@ public class OrderController { this.storageService = storageService; this.invoiceService = invoiceService; this.qrBillService = qrBillService; + this.twintPaymentService = twintPaymentService; } @@ -185,6 +191,51 @@ public class OrderController { .contentType(MediaType.APPLICATION_PDF) .body(pdf); } + + @GetMapping("/{orderId}/twint") + public ResponseEntity> getTwintPayment(@PathVariable UUID orderId) { + if (!orderRepo.existsById(orderId)) { + return ResponseEntity.notFound().build(); + } + + byte[] qrPng = twintPaymentService.generateQrPng(360); + String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); + + Map data = new HashMap<>(); + data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl()); + data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); + data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); + data.put("qrImageDataUri", qrDataUri); + return ResponseEntity.ok(data); + } + + @GetMapping("/{orderId}/twint/open") + public ResponseEntity openTwintPayment(@PathVariable UUID orderId) { + if (!orderRepo.existsById(orderId)) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.status(302) + .location(URI.create(twintPaymentService.getTwintPaymentUrl())) + .build(); + } + + @GetMapping("/{orderId}/twint/qr") + public ResponseEntity getTwintQr( + @PathVariable UUID orderId, + @RequestParam(defaultValue = "320") int size + ) { + if (!orderRepo.existsById(orderId)) { + return ResponseEntity.notFound().build(); + } + + int normalizedSize = Math.max(200, Math.min(size, 600)); + byte[] png = twintPaymentService.generateQrPng(normalizedSize); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .body(png); + } private String getExtension(String filename) { if (filename == null) return "stl"; diff --git a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java new file mode 100644 index 0000000..97982eb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java @@ -0,0 +1,66 @@ +package com.printcalculator.service; + +import io.nayuki.qrcodegen.QrCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +@Service +public class TwintPaymentService { + + private final String twintPaymentUrl; + + public TwintPaymentService( + @Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}") + String twintPaymentUrl + ) { + this.twintPaymentUrl = twintPaymentUrl; + } + + public String getTwintPaymentUrl() { + return twintPaymentUrl; + } + + public byte[] generateQrPng(int sizePx) { + try { + // Use High Error Correction for financial QR codes + QrCode qrCode = QrCode.encodeText(twintPaymentUrl, QrCode.Ecc.HIGH); + + // Standard QR quiet zone is 4 modules + int borderModules = 4; + int fullModules = qrCode.size + borderModules * 2; + int scale = Math.max(1, sizePx / fullModules); + int imageSize = fullModules * scale; + + BufferedImage image = new BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, imageSize, imageSize); + graphics.setColor(Color.BLACK); + + for (int y = 0; y < qrCode.size; y++) { + for (int x = 0; x < qrCode.size; x++) { + if (qrCode.getModule(x, y)) { + int px = (x + borderModules) * scale; + int py = (y + borderModules) * scale; + graphics.fillRect(px, py, scale, scale); + } + } + } + } finally { + graphics.dispose(); + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to generate TWINT QR image.", ex); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 3d70ca6..82887cc 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -23,3 +23,6 @@ spring.servlet.multipart.max-request-size=200MB clamav.host=${CLAMAV_HOST:clamav} clamav.port=${CLAMAV_PORT:3310} clamav.enabled=${CLAMAV_ENABLED:false} + +# TWINT Configuration +payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} diff --git a/docker-compose.yml b/docker-compose.yml index f347611..39c038b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,7 +58,10 @@ services: container_name: print-calculator-clamav ports: - "3310:3310" + volumes: + - clamav_db:/var/lib/clamav restart: unless-stopped volumes: postgres_data: + clamav_db: diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c74044d..ac184d1 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -33,6 +33,10 @@ export const routes: Routes = [ path: 'payment/:orderId', loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) }, + { + path: 'order-confirmed/:orderId', + loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent) + }, { path: '', loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 928f957..72b1ed3 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -46,8 +46,8 @@
-

Analisi in corso...

-

Stiamo analizzando la geometria e calcolando il percorso utensile.

+

{{ 'CALC.ANALYZING_TITLE' | translate }}

+

{{ 'CALC.ANALYZING_TEXT' | translate }}

} @else if (result()) { diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index f33740d..769cb0e 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -21,7 +21,7 @@
- * Include {{ result().setupCost | currency:result().currency }} Setup Cost + {{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}
@if (result().notes) { @@ -46,7 +46,7 @@
- +
- +
- +
@@ -151,7 +151,7 @@ type="submit" [disabled]="items().length === 0 || loading()" [fullWidth]="true"> - {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }} + {{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.html b/frontend/src/app/features/calculator/components/user-details/user-details.component.html index a16b8e2..3f934cd 100644 --- a/frontend/src/app/features/calculator/components/user-details/user-details.component.html +++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.html @@ -9,8 +9,8 @@
@@ -18,8 +18,8 @@
@@ -31,9 +31,9 @@
@@ -41,9 +41,9 @@
@@ -53,8 +53,8 @@ @@ -64,8 +64,8 @@
@@ -73,8 +73,8 @@
diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 55fa6ae..6841caa 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -161,6 +161,13 @@ export class QuoteEstimatorService { responseType: 'blob' }); } + + getTwintPayment(orderId: string): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers }); + } calculate(request: QuoteRequest): Observable { console.log('QuoteEstimatorService: Calculating quote...', request); diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index d3972a3..7c83827 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -21,7 +21,7 @@

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

- +
@@ -41,8 +41,8 @@
- - + +
@@ -87,8 +87,8 @@
- - + +
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html index c7e33a1..86a2335 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html @@ -60,8 +60,8 @@
- PDF - 3D + {{ 'CONTACT.FILE_TYPE_PDF' | translate }} + {{ 'CONTACT.FILE_TYPE_3D' | translate }}
{{ file.file.name }}
diff --git a/frontend/src/app/features/contact/contact-page.component.html b/frontend/src/app/features/contact/contact-page.component.html index 75d5c3c..efd8bb5 100644 --- a/frontend/src/app/features/contact/contact-page.component.html +++ b/frontend/src/app/features/contact/contact-page.component.html @@ -1,7 +1,7 @@

{{ 'CONTACT.TITLE' | translate }}

-

Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.

+

{{ 'CONTACT.HERO_SUBTITLE' | translate }}

diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 4d4b943..5aca4a3 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -2,22 +2,18 @@
-

Stampa 3D tecnica per aziende, freelance e maker

-

- Prezzo e tempi in pochi secondi.
- Dal file 3D al pezzo finito. -

+

{{ 'HOME.HERO_EYEBROW' | translate }}

+

- Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese. + {{ 'HOME.HERO_LEAD' | translate }}

- Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. - Se devi ancora crearlo, il nostro team di design lo progetterà per te. + {{ 'HOME.HERO_SUBTITLE' | translate }}

- Calcola Preventivo - Vai allo shop - Parla con noi + {{ 'HOME.BTN_CALCULATE' | translate }} + {{ 'HOME.BTN_SHOP' | translate }} + {{ 'HOME.BTN_CONTACT' | translate }}
@@ -26,31 +22,31 @@
-

Preventivo immediato in pochi secondi

+

{{ 'HOME.SEC_CALC_TITLE' | translate }}

- Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing. + {{ 'HOME.SEC_CALC_SUBTITLE' | translate }}

    -
  • Formati supportati: STL, 3MF, STEP, OBJ
  • -
  • Qualità: bozza, standard, alta definizione
  • +
  • {{ 'HOME.SEC_CALC_LIST_1' | translate }}
  • +
  • {{ 'HOME.SEC_CALC_LIST_2' | translate }}
-

Calcolo automatico

-

Prezzo e tempi in un click

+

{{ 'HOME.CARD_CALC_EYEBROW' | translate }}

+

{{ 'HOME.CARD_CALC_TITLE' | translate }}

- Senza registrazione + {{ 'HOME.CARD_CALC_TAG' | translate }}
    -
  • Carica il file 3D
  • -
  • Scegli materiale e qualità
  • -
  • Ricevi subito costo e tempo
  • +
  • {{ 'HOME.CARD_CALC_STEP_1' | translate }}
  • +
  • {{ 'HOME.CARD_CALC_STEP_2' | translate }}
  • +
  • {{ 'HOME.CARD_CALC_STEP_3' | translate }}
- Apri calcolatore - Parla con noi + {{ 'HOME.BTN_OPEN_CALC' | translate }} + {{ 'HOME.BTN_CONTACT' | translate }}
@@ -60,9 +56,9 @@
-

Cosa puoi ottenere

+

{{ 'HOME.SEC_CAP_TITLE' | translate }}

- Produzione su misura per prototipi, piccole serie e pezzi personalizzati. + {{ 'HOME.SEC_CAP_SUBTITLE' | translate }}

@@ -70,29 +66,29 @@
-

Prototipazione veloce

-

Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.

+

{{ 'HOME.CAP_1_TITLE' | translate }}

+

{{ 'HOME.CAP_1_TEXT' | translate }}

-

Pezzi personalizzati

-

Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.

+

{{ 'HOME.CAP_2_TITLE' | translate }}

+

{{ 'HOME.CAP_2_TEXT' | translate }}

-

Piccole serie

-

Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.

+

{{ 'HOME.CAP_3_TITLE' | translate }}

+

{{ 'HOME.CAP_3_TEXT' | translate }}

-

Consulenza e CAD

-

Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.

+

{{ 'HOME.CAP_4_TITLE' | translate }}

+

{{ 'HOME.CAP_4_TEXT' | translate }}

@@ -101,33 +97,32 @@
-

Shop di soluzioni tecniche pronte

+

{{ 'HOME.SEC_SHOP_TITLE' | translate }}

- Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con - funzionalità concrete. + {{ 'HOME.SEC_SHOP_TEXT' | translate }}

    -
  • Accessori funzionali per officine e laboratori
  • -
  • Ricambi e componenti difficili da reperire
  • -
  • Supporti e organizzatori per migliorare i flussi di lavoro
  • +
  • {{ 'HOME.SEC_SHOP_LIST_1' | translate }}
  • +
  • {{ 'HOME.SEC_SHOP_LIST_2' | translate }}
  • +
  • {{ 'HOME.SEC_SHOP_LIST_3' | translate }}
- Scopri i prodotti - Richiedi una soluzione + {{ 'HOME.BTN_DISCOVER' | translate }} + {{ 'HOME.BTN_REQ_SOLUTION' | translate }}
-

Best seller tecnici

-

Soluzioni provate sul campo e già pronte alla spedizione.

+

{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}

-

Kit pronti all'uso

-

Componenti compatibili e facili da montare senza sorprese.

+

{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}

-

Su richiesta

-

Non trovi quello che serve? Lo progettiamo e lo produciamo per te.

+

{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}

@@ -136,17 +131,16 @@
-

Su di noi

+

{{ 'HOME.SEC_ABOUT_TITLE' | translate }}

- 3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale - alla produzione, con tempi chiari e supporto diretto. + {{ 'HOME.SEC_ABOUT_TEXT' | translate }}

- Contattaci + {{ 'HOME.BTN_CONTACT' | translate }}
- Foto Founders + {{ 'HOME.FOUNDERS_PHOTO' | translate }}
diff --git a/frontend/src/app/features/order-confirmed/order-confirmed.component.html b/frontend/src/app/features/order-confirmed/order-confirmed.component.html new file mode 100644 index 0000000..c443bce --- /dev/null +++ b/frontend/src/app/features/order-confirmed/order-confirmed.component.html @@ -0,0 +1,25 @@ +
+

{{ 'ORDER_CONFIRMED.TITLE' | translate }}

+

{{ 'ORDER_CONFIRMED.SUBTITLE' | translate }}

+
+ +
+
+ +
{{ 'ORDER_CONFIRMED.STATUS' | translate }}
+

{{ 'ORDER_CONFIRMED.HEADING' | translate }}

+

+ {{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: #{{ orderId.substring(0, 8) }} +

+ +
+

{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}

+

{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}

+
+ +
+ {{ 'ORDER_CONFIRMED.BACK_HOME' | translate }} +
+
+
+
diff --git a/frontend/src/app/features/order-confirmed/order-confirmed.component.scss b/frontend/src/app/features/order-confirmed/order-confirmed.component.scss new file mode 100644 index 0000000..b38000b --- /dev/null +++ b/frontend/src/app/features/order-confirmed/order-confirmed.component.scss @@ -0,0 +1,62 @@ +.hero { + padding: var(--space-12) 0 var(--space-8); + text-align: center; + + h1 { + font-size: 2.4rem; + margin-bottom: var(--space-2); + } +} + +.subtitle { + font-size: 1.1rem; + color: var(--color-text-muted); + max-width: 720px; + margin: 0 auto; +} + +.confirmation-layout { + max-width: 760px; + margin: 0 auto var(--space-12); +} + +.status-badge { + display: inline-block; + padding: 0.35rem 0.65rem; + border-radius: 999px; + background: #eef8f0; + color: #136f2d; + font-weight: 700; + font-size: 0.85rem; + margin-bottom: var(--space-4); +} + +h2 { + margin: 0 0 var(--space-3); +} + +.order-ref { + margin: 0 0 var(--space-4); + color: var(--color-text-muted); +} + +.message-block { + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-5); + margin-bottom: var(--space-6); + + p { + margin: 0; + line-height: 1.45; + } + + p + p { + margin-top: var(--space-3); + } +} + +.actions { + max-width: 320px; +} diff --git a/frontend/src/app/features/order-confirmed/order-confirmed.component.ts b/frontend/src/app/features/order-confirmed/order-confirmed.component.ts new file mode 100644 index 0000000..60abd3f --- /dev/null +++ b/frontend/src/app/features/order-confirmed/order-confirmed.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; + +@Component({ + selector: 'app-order-confirmed', + standalone: true, + imports: [CommonModule, TranslateModule, AppButtonComponent, AppCardComponent], + templateUrl: './order-confirmed.component.html', + styleUrl: './order-confirmed.component.scss' +}) +export class OrderConfirmedComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + + orderId: string | null = null; + + ngOnInit(): void { + this.orderId = this.route.snapshot.paramMap.get('orderId'); + } + + goHome(): void { + this.router.navigate(['/']); + } +} diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html index a86265b..7728b4b 100644 --- a/frontend/src/app/features/payment/payment.component.html +++ b/frontend/src/app/features/payment/payment.component.html @@ -17,26 +17,35 @@ class="type-option" [class.selected]="selectedPaymentMethod === 'twint'" (click)="selectPayment('twint')"> - TWINT + {{ 'PAYMENT.METHOD_TWINT' | translate }}
- QR Bill / Bank Transfer + {{ 'PAYMENT.METHOD_BANK' | translate }}
-
+

{{ 'PAYMENT.TWINT_TITLE' | translate }}

-
- QR CODE -
+ TWINT payment QR

{{ 'PAYMENT.TWINT_DESC' | translate }}

+

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

+
+ + {{ 'PAYMENT.TWINT_OPEN' | translate }} + +

{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}

@@ -49,6 +58,7 @@

{{ 'PAYMENT.BANK_OWNER' | translate }}: 3D Fab Switzerland

{{ 'PAYMENT.BANK_IBAN' | translate }}: CH98 0000 0000 0000 0000 0

{{ 'PAYMENT.BANK_REF' | translate }}: {{ o.id }}

+

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss index 4d3ba7f..3008d79 100644 --- a/frontend/src/app/features/payment/payment.component.scss +++ b/frontend/src/app/features/payment/payment.component.scss @@ -105,23 +105,33 @@ } } +.payment-details-twint { + border: 1px solid #dcd5ff; + background: linear-gradient(180deg, #fcfbff 0%, #f6f4ff 100%); +} + .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); + .twint-qr { + width: 240px; + height: 240px; + background-color: #fff; + border: 1px solid var(--color-border); border-radius: var(--radius-md); + padding: var(--space-2); + margin-bottom: var(--space-4); + object-fit: contain; + box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08); + } + + .twint-mobile-action { + width: 100%; + max-width: 320px; + margin-top: var(--space-3); } .amount { @@ -132,6 +142,12 @@ } } +.billing-hint { + margin-top: var(--space-3); + font-size: 0.95rem; + color: var(--color-text-muted); +} + .bank-details { p { margin-bottom: var(--space-2); diff --git a/frontend/src/app/features/payment/payment.component.ts b/frontend/src/app/features/payment/payment.component.ts index 520e486..9083732 100644 --- a/frontend/src/app/features/payment/payment.component.ts +++ b/frontend/src/app/features/payment/payment.component.ts @@ -5,6 +5,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { TranslateModule } from '@ngx-translate/core'; +import { environment } from '../../../environments/environment'; @Component({ selector: 'app-payment', @@ -23,11 +24,14 @@ export class PaymentComponent implements OnInit { order = signal(null); loading = signal(true); error = signal(null); + twintOpenUrl = signal(null); + twintQrUrl = signal(null); ngOnInit(): void { this.orderId = this.route.snapshot.paramMap.get('orderId'); if (this.orderId) { this.loadOrder(); + this.loadTwintPayment(); } else { this.error.set('Order ID not found.'); this.loading.set(false); @@ -68,8 +72,51 @@ export class PaymentComponent implements OnInit { }); } + loadTwintPayment() { + if (!this.orderId) return; + this.quoteService.getTwintPayment(this.orderId).subscribe({ + next: (res) => { + const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null; + const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null; + this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl)); + this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath)); + }, + error: (err) => { + console.error('Failed to load TWINT payment details', err); + } + }); + } + + openTwintPayment(): void { + const openUrl = this.twintOpenUrl(); + if (typeof window !== 'undefined' && openUrl) { + window.location.href = openUrl; + } + } + + getTwintQrUrl(): string { + return this.twintQrUrl() ?? ''; + } + + onTwintQrError(): void { + this.twintQrUrl.set(null); + } + + private resolveApiUrl(urlOrPath: string | null | undefined): string | null { + if (!urlOrPath) return null; + if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) { + return urlOrPath; + } + const base = (environment.apiUrl || '').replace(/\/$/, ''); + const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`; + return `${base}${path}`; + } + completeOrder(): void { - alert('Payment Simulated! Order marked as PAID.'); - this.router.navigate(['/']); + if (!this.orderId) { + this.router.navigate(['/']); + return; + } + this.router.navigate(['/order-confirmed', this.orderId]); } } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index a08b543..58f2937 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -20,6 +20,6 @@
} @else { -

Prodotto non trovato.

+

{{ 'SHOP.NOT_FOUND' | translate }}

}
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 23ce97f..45217d3 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -180,10 +180,13 @@ "METHOD": "Payment Method", "TWINT_TITLE": "Pay with TWINT", "TWINT_DESC": "Scan the code with your TWINT app", + "TWINT_OPEN": "Open directly in TWINT", + "TWINT_LINK": "Open payment link", "BANK_TITLE": "Bank Transfer", "BANK_OWNER": "Owner", "BANK_IBAN": "IBAN", "BANK_REF": "Reference", + "BILLING_INFO_HINT": "Add the same information used in billing.", "DOWNLOAD_QR": "Download QR-Invoice (PDF)", "CONFIRM": "Confirm Order", "SUMMARY_TITLE": "Order Summary", @@ -192,5 +195,15 @@ "SETUP_FEE": "Setup Fee", "TOTAL": "Total", "LOADING": "Loading order details..." + }, + "ORDER_CONFIRMED": { + "TITLE": "Order Confirmed", + "SUBTITLE": "Payment received. Your order is now being processed.", + "STATUS": "Processing", + "HEADING": "We are preparing your order", + "ORDER_REF": "Order reference", + "PROCESSING_TEXT": "As soon as payment is confirmed, your order will move to production.", + "EMAIL_TEXT": "We will send you an email update with status and next steps.", + "BACK_HOME": "Back to Home" } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 70ce4f9..9ccaca4 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -11,6 +11,52 @@ "TERMS": "Termini & Condizioni", "CONTACT": "Contattaci" }, + "HOME": { + "HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker", + "HERO_TITLE": "Prezzo e tempi in pochi secondi.
Dal file 3D al pezzo finito.", + "HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", + "HERO_SUBTITLE": "Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. Se devi ancora crearlo, il nostro team di design lo progetterà per te.", + "BTN_CALCULATE": "Calcola Preventivo", + "BTN_SHOP": "Vai allo shop", + "BTN_CONTACT": "Parla con noi", + "SEC_CALC_TITLE": "Preventivo immediato in pochi secondi", + "SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.", + "SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ", + "SEC_CALC_LIST_2": "Qualità: bozza, standard, alta definizione", + "CARD_CALC_EYEBROW": "Calcolo automatico", + "CARD_CALC_TITLE": "Prezzo e tempi in un click", + "CARD_CALC_TAG": "Senza registrazione", + "CARD_CALC_STEP_1": "Carica il file 3D", + "CARD_CALC_STEP_2": "Scegli materiale e qualità", + "CARD_CALC_STEP_3": "Ricevi subito costo e tempo", + "BTN_OPEN_CALC": "Apri calcolatore", + "SEC_CAP_TITLE": "Cosa puoi ottenere", + "SEC_CAP_SUBTITLE": "Produzione su misura per prototipi, piccole serie e pezzi personalizzati.", + "CAP_1_TITLE": "Prototipazione veloce", + "CAP_1_TEXT": "Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.", + "CAP_2_TITLE": "Pezzi personalizzati", + "CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.", + "CAP_3_TITLE": "Piccole serie", + "CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.", + "CAP_4_TITLE": "Consulenza e CAD", + "CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.", + "SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte", + "SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.", + "SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori", + "SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire", + "SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro", + "BTN_DISCOVER": "Scopri i prodotti", + "BTN_REQ_SOLUTION": "Richiedi una soluzione", + "CARD_SHOP_1_TITLE": "Best seller tecnici", + "CARD_SHOP_1_TEXT": "Soluzioni provate sul campo e già pronte alla spedizione.", + "CARD_SHOP_2_TITLE": "Kit pronti all'uso", + "CARD_SHOP_2_TEXT": "Componenti compatibili e facili da montare senza sorprese.", + "CARD_SHOP_3_TITLE": "Su richiesta", + "CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.", + "SEC_ABOUT_TITLE": "Su di noi", + "SEC_ABOUT_TEXT": "3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale alla produzione, con tempi chiari e supporto diretto.", + "FOUNDERS_PHOTO": "Foto Founders" + }, "CALC": { "TITLE": "Calcola Preventivo 3D", "SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP...) e ricevi una stima immediata di costi e tempi di stampa.", @@ -47,13 +93,53 @@ "BENEFITS_1": "Preventivo automatico con costo e tempo immediati", "BENEFITS_2": "Materiali selezionati e qualità controllata", "BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche", - "ERR_FILE_REQUIRED": "Il file è obbligatorio." + "ERR_FILE_REQUIRED": "Il file è obbligatorio.", + "ANALYZING_TITLE": "Analisi in corso...", + "ANALYZING_TEXT": "Stiamo analizzando la geometria e calcolando il percorso utensile.", + "QTY_SHORT": "QTÀ", + "COLOR_LABEL": "COLORE", + "ADD_FILES": "Aggiungi file", + "UPLOADING": "Caricamento...", + "PROCESSING": "Elaborazione...", + "NOTES_PLACEHOLDER": "Istruzioni specifiche...", + "SETUP_NOTE": "* Include {{cost}} Costo di Setup" + }, + "QUOTE": { + "PROCEED_ORDER": "Procedi con l'ordine", + "CONSULT": "Richiedi Consulenza", + "TOTAL": "Totale" + }, + "USER_DETAILS": { + "TITLE": "I tuoi dati", + "NAME": "Nome", + "NAME_PLACEHOLDER": "Il tuo nome", + "SURNAME": "Cognome", + "SURNAME_PLACEHOLDER": "Il tuo cognome", + "EMAIL": "Email", + "EMAIL_PLACEHOLDER": "tua@email.com", + "PHONE": "Telefono", + "PHONE_PLACEHOLDER": "+41 ...", + "ADDRESS": "Indirizzo", + "ADDRESS_PLACEHOLDER": "Via e numero", + "ZIP": "CAP", + "ZIP_PLACEHOLDER": "0000", + "CITY": "Città", + "CITY_PLACEHOLDER": "Città", + "SUBMIT": "Procedi", + "SUMMARY_TITLE": "Riepilogo" + }, + "COMMON": { + "REQUIRED": "Campo obbligatorio", + "INVALID_EMAIL": "Email non valida", + "BACK": "Indietro", + "OPTIONAL": "(Opzionale)" }, "SHOP": { "TITLE": "Soluzioni tecniche", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici", "ADD_CART": "Aggiungi al Carrello", - "BACK": "Torna allo Shop" + "BACK": "Torna allo Shop", + "NOT_FOUND": "Prodotto non trovato." }, "ABOUT": { "TITLE": "Chi Siamo", @@ -126,7 +212,10 @@ "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.", "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.", - "SEND_ANOTHER": "Invia un altro messaggio" + "SEND_ANOTHER": "Invia un altro messaggio", + "HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D" }, "CHECKOUT": { "TITLE": "Checkout", @@ -152,17 +241,23 @@ "SETUP_FEE": "Costo di Avvio", "TOTAL": "Totale", "QTY": "Qtà", - "SHIPPING": "Spedizione" + "SHIPPING": "Spedizione", + "INVALID_EMAIL": "Email non valida", + "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", + "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)" }, "PAYMENT": { "TITLE": "Pagamento", "METHOD": "Metodo di Pagamento", "TWINT_TITLE": "Paga con TWINT", "TWINT_DESC": "Inquadra il codice con l'app TWINT", + "TWINT_OPEN": "Apri direttamente in TWINT", + "TWINT_LINK": "Apri link di pagamento", "BANK_TITLE": "Bonifico Bancario", "BANK_OWNER": "Titolare", "BANK_IBAN": "IBAN", "BANK_REF": "Riferimento", + "BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.", "DOWNLOAD_QR": "Scarica QR-Fattura (PDF)", "CONFIRM": "Conferma Ordine", "SUMMARY_TITLE": "Riepilogo Ordine", @@ -170,6 +265,18 @@ "SHIPPING": "Spedizione", "SETUP_FEE": "Costo Setup", "TOTAL": "Totale", - "LOADING": "Caricamento dettagli ordine..." + "LOADING": "Caricamento dettagli ordine...", + "METHOD_TWINT": "TWINT", + "METHOD_BANK": "Fattura QR / Bonifico" + }, + "ORDER_CONFIRMED": { + "TITLE": "Ordine Confermato", + "SUBTITLE": "Pagamento registrato. Il tuo ordine è ora in elaborazione.", + "STATUS": "In elaborazione", + "HEADING": "Stiamo preparando il tuo ordine", + "ORDER_REF": "Riferimento ordine", + "PROCESSING_TEXT": "Non appena confermiamo il pagamento, il tuo ordine passerà in produzione.", + "EMAIL_TEXT": "Ti invieremo una email con aggiornamento stato e prossimi step.", + "BACK_HOME": "Torna alla Home" } } -- 2.49.1 From 1d8223056413255aebed627b85b316078f2673da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 20 Feb 2026 17:11:44 +0100 Subject: [PATCH 27/72] feat(front-enc): fix back-ground --- frontend/src/app/features/payment/payment.component.html | 2 +- frontend/src/app/features/payment/payment.component.scss | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html index 7728b4b..0453380 100644 --- a/frontend/src/app/features/payment/payment.component.html +++ b/frontend/src/app/features/payment/payment.component.html @@ -28,7 +28,7 @@
-
+

{{ 'PAYMENT.TWINT_TITLE' | translate }}

diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss index 3008d79..4aed10d 100644 --- a/frontend/src/app/features/payment/payment.component.scss +++ b/frontend/src/app/features/payment/payment.component.scss @@ -105,10 +105,6 @@ } } -.payment-details-twint { - border: 1px solid #dcd5ff; - background: linear-gradient(180deg, #fcfbff 0%, #f6f4ff 100%); -} .qr-placeholder { display: flex; -- 2.49.1 From c3f95399882f7a7ade53bbe3c736c2b6dcca3567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 20 Feb 2026 17:47:34 +0100 Subject: [PATCH 28/72] feat(front-enc): --- .../features/payment/payment.component.html | 4 ++-- .../features/payment/payment.component.scss | 22 +++++++++++++++++++ .../app/features/payment/payment.component.ts | 2 +- .../app-button/app-button.component.scss | 10 +++++---- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html index 0453380..6e1852d 100644 --- a/frontend/src/app/features/payment/payment.component.html +++ b/frontend/src/app/features/payment/payment.component.html @@ -28,7 +28,7 @@
-
+

{{ 'PAYMENT.TWINT_TITLE' | translate }}

@@ -42,7 +42,7 @@

{{ 'PAYMENT.TWINT_DESC' | translate }}

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

- + {{ 'PAYMENT.TWINT_OPEN' | translate }}
diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss index 4aed10d..843414a 100644 --- a/frontend/src/app/features/payment/payment.component.scss +++ b/frontend/src/app/features/payment/payment.component.scss @@ -95,6 +95,28 @@ margin-bottom: var(--space-6); border: 1px solid var(--color-border); + &.text-center { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + + .details-header { + width: 100%; + text-align: center; + } + + .qr-placeholder { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + } + .details-header { margin-bottom: var(--space-4); h4 { diff --git a/frontend/src/app/features/payment/payment.component.ts b/frontend/src/app/features/payment/payment.component.ts index 9083732..f5b1c26 100644 --- a/frontend/src/app/features/payment/payment.component.ts +++ b/frontend/src/app/features/payment/payment.component.ts @@ -90,7 +90,7 @@ export class PaymentComponent implements OnInit { openTwintPayment(): void { const openUrl = this.twintOpenUrl(); if (typeof window !== 'undefined' && openUrl) { - window.location.href = openUrl; + window.open(openUrl, '_blank'); } } diff --git a/frontend/src/app/shared/components/app-button/app-button.component.scss b/frontend/src/app/shared/components/app-button/app-button.component.scss index 407d667..55bb342 100644 --- a/frontend/src/app/shared/components/app-button/app-button.component.scss +++ b/frontend/src/app/shared/components/app-button/app-button.component.scss @@ -32,12 +32,14 @@ .btn-outline { background-color: transparent; - border-color: var(--color-border); - color: var(--color-text); + border-color: var(--color-brand); + border-width: 2px; + padding: calc(0.5rem - 1px) calc(1rem - 1px); + color: var(--color-neutral-900); + font-weight: 600; &:hover:not(:disabled) { - border-color: var(--color-brand); + background-color: var(--color-brand); color: var(--color-neutral-900); - background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */ } } -- 2.49.1 From 0438ba3ae59f1fe0b5e41d2888e567d1b284d35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 23 Feb 2026 15:23:11 +0100 Subject: [PATCH 29/72] feat(back-end): email service and test --- backend/build.gradle | 1 + .../printcalculator/BackendApplication.java | 2 + .../controller/DevEmailTestController.java | 44 ++++++++++ .../event/OrderCreatedEvent.java | 16 ++++ .../event/listener/OrderEmailListener.java | 77 ++++++++++++++++ .../printcalculator/service/OrderService.java | 14 ++- .../email/EmailNotificationService.java | 17 ++++ .../email/SmtpEmailNotificationService.java | 54 ++++++++++++ .../src/main/resources/application.properties | 13 +++ .../templates/email/order-confirmation.html | 88 +++++++++++++++++++ 10 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java create mode 100644 backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java create mode 100644 backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java create mode 100644 backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java create mode 100644 backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java create mode 100644 backend/src/main/resources/templates/email/order-confirmation.html diff --git a/backend/build.gradle b/backend/build.gradle index b15d8ef..b465785 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -37,6 +37,7 @@ dependencies { 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' + implementation 'org.springframework.boot:spring-boot-starter-mail' diff --git a/backend/src/main/java/com/printcalculator/BackendApplication.java b/backend/src/main/java/com/printcalculator/BackendApplication.java index e9ee050..c528427 100644 --- a/backend/src/main/java/com/printcalculator/BackendApplication.java +++ b/backend/src/main/java/com/printcalculator/BackendApplication.java @@ -3,12 +3,14 @@ package com.printcalculator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication @EnableTransactionManagement @EnableScheduling +@EnableAsync public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java b/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java new file mode 100644 index 0000000..e60726d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java @@ -0,0 +1,44 @@ +package com.printcalculator.controller; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/dev/email") +@Profile("local") +public class DevEmailTestController { + + private final TemplateEngine templateEngine; + + public DevEmailTestController(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + + @GetMapping("/test-template") + public ResponseEntity testTemplate() { + Context context = new Context(); + Map templateData = new HashMap<>(); + templateData.put("customerName", "Mario Rossi"); + templateData.put("orderId", UUID.randomUUID()); + templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); + templateData.put("totalCost", "45.50"); + + context.setVariables(templateData); + String html = templateEngine.process("email/order-confirmation", context); + + return ResponseEntity.ok() + .header("Content-Type", "text/html; charset=utf-8") + .body(html); + } +} diff --git a/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java b/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java new file mode 100644 index 0000000..29ccde1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java @@ -0,0 +1,16 @@ +package com.printcalculator.event; + +import com.printcalculator.entity.Order; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class OrderCreatedEvent extends ApplicationEvent { + + private final Order order; + + public OrderCreatedEvent(Object source, Order order) { + super(source); + this.order = order; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java new file mode 100644 index 0000000..18b95cc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -0,0 +1,77 @@ +package com.printcalculator.event.listener; + +import com.printcalculator.entity.Order; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.service.email.EmailNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEmailListener { + + private final EmailNotificationService emailNotificationService; + + @Value("${app.mail.admin.enabled:true}") + private boolean adminMailEnabled; + + @Value("${app.mail.admin.address:}") + private String adminMailAddress; + + @Async + @EventListener + public void handleOrderCreatedEvent(OrderCreatedEvent event) { + Order order = event.getOrder(); + log.info("Processing OrderCreatedEvent for order id: {}", order.getId()); + + try { + sendCustomerConfirmationEmail(order); + + if (adminMailEnabled && adminMailAddress != null && !adminMailAddress.isEmpty()) { + sendAdminNotificationEmail(order); + } + } catch (Exception e) { + log.error("Failed to process email notifications for order id: {}", order.getId(), e); + } + } + + private void sendCustomerConfirmationEmail(Order order) { + Map templateData = new HashMap<>(); + templateData.put("customerName", order.getCustomer().getFirstName()); + templateData.put("orderId", order.getId()); + templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); + templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); + + emailNotificationService.sendEmail( + order.getCustomer().getEmail(), + "Conferma Ordine #" + order.getId() + " - 3D-Fab", + "order-confirmation", + templateData + ); + } + + private void sendAdminNotificationEmail(Order order) { + Map templateData = new HashMap<>(); + templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName()); + templateData.put("orderId", order.getId()); + templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); + templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); + + // Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro + emailNotificationService.sendEmail( + adminMailAddress, + "Nuovo Ordine Ricevuto #" + order.getId() + " - " + order.getCustomer().getLastName(), + "order-confirmation", + templateData + ); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 02d3289..569269a 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -8,9 +8,10 @@ import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.event.OrderCreatedEvent; +import org.springframework.context.ApplicationEventPublisher; 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; @@ -34,6 +35,7 @@ public class OrderService { private final StorageService storageService; private final InvoicePdfRenderingService invoiceService; private final QrBillService qrBillService; + private final ApplicationEventPublisher eventPublisher; public OrderService(OrderRepository orderRepo, OrderItemRepository orderItemRepo, @@ -42,7 +44,8 @@ public class OrderService { CustomerRepository customerRepo, StorageService storageService, InvoicePdfRenderingService invoiceService, - QrBillService qrBillService) { + QrBillService qrBillService, + ApplicationEventPublisher eventPublisher) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; @@ -51,6 +54,7 @@ public class OrderService { this.storageService = storageService; this.invoiceService = invoiceService; this.qrBillService = qrBillService; + this.eventPublisher = eventPublisher; } @Transactional @@ -194,8 +198,12 @@ public class OrderService { // Generate Invoice and QR Bill generateAndSaveDocuments(order, savedItems); + + Order savedOrder = orderRepo.save(order); + + eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder)); - return orderRepo.save(order); + return savedOrder; } private void generateAndSaveDocuments(Order order, List items) { diff --git a/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java new file mode 100644 index 0000000..5c2448a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java @@ -0,0 +1,17 @@ +package com.printcalculator.service.email; + +import java.util.Map; + +public interface EmailNotificationService { + + /** + * Sends an HTML email using a Thymeleaf template. + * + * @param to The recipient email address. + * @param subject The subject of the email. + * @param templateName The name of the Thymeleaf template (e.g., "order-confirmation"). + * @param contextData The data to populate the template with. + */ + void sendEmail(String to, String subject, String templateName, Map contextData); + +} diff --git a/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java new file mode 100644 index 0000000..3e0987b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java @@ -0,0 +1,54 @@ +package com.printcalculator.service.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmtpEmailNotificationService implements EmailNotificationService { + + private final JavaMailSender emailSender; + private final TemplateEngine templateEngine; + + @Value("${app.mail.from}") + private String fromAddress; + + @Override + public void sendEmail(String to, String subject, String templateName, Map contextData) { + log.info("Preparing to send email to {} with template {}", to, templateName); + + try { + Context context = new Context(); + context.setVariables(contextData); + + String process = templateEngine.process("email/" + templateName, context); + MimeMessage mimeMessage = emailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setFrom(fromAddress); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(process, true); // true indicates HTML format + + emailSender.send(mimeMessage); + log.info("Email successfully sent to {}", to); + + } catch (MessagingException e) { + log.error("Failed to send email to {}", to, e); + // Non blocco l'ordine se l'email fallisce, ma loggo l'errore adeguatamente. + } catch (Exception e) { + log.error("Unexpected error while sending email to {}", to, e); + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 82887cc..7297d1e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,3 +26,16 @@ clamav.enabled=${CLAMAV_ENABLED:false} # TWINT Configuration payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} + +# Mail Configuration +spring.mail.host=${MAIL_HOST:mail.infomaniak.com} +spring.mail.port=${MAIL_PORT:587} +spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch} +spring.mail.password=${MAIL_PASSWORD:ht*44k+Tq39R+R-O} +spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false} +spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false} + +# Application Mail Settings +app.mail.from=${APP_MAIL_FROM:noreply@printcalculator.local} +app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} +app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} diff --git a/backend/src/main/resources/templates/email/order-confirmation.html b/backend/src/main/resources/templates/email/order-confirmation.html new file mode 100644 index 0000000..fb75eea --- /dev/null +++ b/backend/src/main/resources/templates/email/order-confirmation.html @@ -0,0 +1,88 @@ + + + + + Conferma Ordine + + + +
+
+

Grazie per il tuo ordine!

+
+
+

Ciao Cliente,

+

Abbiamo ricevuto il tuo ordine e stiamo iniziando a elaborarlo. Ecco un riepilogo dei dettagli:

+ +
+ + + + + + + + + + + + + +
Numero Ordine:#0000
Data:01/01/2026
Costo totale:0.00 CHF
+
+ +

Se hai domande o dubbi, non esitare a contattarci.

+
+ +
+ + -- 2.49.1 From abf47e0003fb85a61129cfd1048a593c63540ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 23 Feb 2026 16:20:11 +0100 Subject: [PATCH 30/72] feat(back-end): email service and test --- backend/src/main/resources/application.properties | 2 +- docker-compose.deploy.yml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 7297d1e..810d11e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -36,6 +36,6 @@ spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false} spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false} # Application Mail Settings -app.mail.from=${APP_MAIL_FROM:noreply@printcalculator.local} +app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index da3d8cc..912b2c1 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -13,6 +13,15 @@ services: - CLAMAV_HOST=${CLAMAV_HOST} - CLAMAV_PORT=${CLAMAV_PORT} - CLAMAV_ENABLED=${CLAMAV_ENABLED} + - MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com} + - MAIL_PORT=${MAIL_PORT:-587} + - MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch} + - MAIL_PASSWORD=${MAIL_PASSWORD:-} + - MAIL_SMTP_AUTH=${MAIL_SMTP_AUTH:-true} + - MAIL_SMTP_STARTTLS=${MAIL_SMTP_STARTTLS:-true} + - APP_MAIL_FROM=${APP_MAIL_FROM:-info@3d-fab.ch} + - APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true} + - APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always -- 2.49.1 From ec4d512136d03a7e76739b06190547b600619565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 23 Feb 2026 17:30:43 +0100 Subject: [PATCH 31/72] feat(back-end front-end): uuid truncated for better UX --- .../controller/DevEmailTestController.java | 5 +++- .../controller/OrderController.java | 13 ++++++++-- .../com/printcalculator/dto/OrderDto.java | 4 ++++ .../com/printcalculator/entity/Order.java | 12 +++++++++- .../event/listener/OrderEmailListener.java | 24 +++++++++++++++++-- .../printcalculator/service/OrderService.java | 10 +++++++- .../service/QrBillService.java | 3 ++- .../src/main/resources/application.properties | 1 + .../templates/email/order-confirmation.html | 9 +++++-- frontend/src/app/app.routes.ts | 4 ++++ .../order-confirmed.component.html | 4 ++-- .../order-confirmed.component.ts | 20 ++++++++++++++++ .../features/payment/payment.component.html | 4 ++-- .../app/features/payment/payment.component.ts | 23 +++++++++++++++--- 14 files changed, 119 insertions(+), 17 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java b/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java index e60726d..7ae62a7 100644 --- a/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java +++ b/backend/src/main/java/com/printcalculator/controller/DevEmailTestController.java @@ -29,8 +29,11 @@ public class DevEmailTestController { public ResponseEntity testTemplate() { Context context = new Context(); Map templateData = new HashMap<>(); + UUID orderId = UUID.randomUUID(); templateData.put("customerName", "Mario Rossi"); - templateData.put("orderId", UUID.randomUUID()); + templateData.put("orderId", orderId); + templateData.put("orderNumber", orderId.toString().split("-")[0]); + templateData.put("orderDetailsUrl", "https://tuosito.it/ordine/" + orderId); templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); templateData.put("totalCost", "45.50"); diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 272b41b..3bfc8a9 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -135,7 +135,7 @@ public class OrderController { 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("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase()); vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); @@ -187,7 +187,7 @@ public class OrderController { byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg); return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"") + .header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"") .contentType(MediaType.APPLICATION_PDF) .body(pdf); } @@ -249,6 +249,7 @@ public class OrderController { private OrderDto convertToDto(Order order, List items) { OrderDto dto = new OrderDto(); dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); dto.setStatus(order.getStatus()); dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerPhone(order.getCustomerPhone()); @@ -306,4 +307,12 @@ public class OrderController { return dto; } + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java index 982d9c3..eee6ef0 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -7,6 +7,7 @@ import java.util.UUID; public class OrderDto { private UUID id; + private String orderNumber; private String status; private String customerEmail; private String customerPhone; @@ -27,6 +28,9 @@ public class OrderDto { public UUID getId() { return id; } public void setId(UUID id) { this.id = id; } + public String getOrderNumber() { return orderNumber; } + public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } + public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java index 9ca1ea6..1c59d6c 100644 --- a/backend/src/main/java/com/printcalculator/entity/Order.java +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -138,6 +138,16 @@ public class Order { this.id = id; } + @Transient + public String getOrderNumber() { + if (id == null) { + return null; + } + String rawId = id.toString(); + int dashIndex = rawId.indexOf('-'); + return dashIndex > 0 ? rawId.substring(0, dashIndex) : rawId; + } + public QuoteSession getSourceQuoteSession() { return sourceQuoteSession; } @@ -410,4 +420,4 @@ public class Order { this.paidAt = paidAt; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index 18b95cc..d600847 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -27,6 +27,9 @@ public class OrderEmailListener { @Value("${app.mail.admin.address:}") private String adminMailAddress; + @Value("${app.frontend.base-url:http://localhost:4200}") + private String frontendBaseUrl; + @Async @EventListener public void handleOrderCreatedEvent(OrderCreatedEvent event) { @@ -48,12 +51,14 @@ public class OrderEmailListener { Map templateData = new HashMap<>(); templateData.put("customerName", order.getCustomer().getFirstName()); templateData.put("orderId", order.getId()); + templateData.put("orderNumber", getDisplayOrderNumber(order)); + templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order)); templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); emailNotificationService.sendEmail( order.getCustomer().getEmail(), - "Conferma Ordine #" + order.getId() + " - 3D-Fab", + "Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab", "order-confirmation", templateData ); @@ -63,15 +68,30 @@ public class OrderEmailListener { Map templateData = new HashMap<>(); templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName()); templateData.put("orderId", order.getId()); + templateData.put("orderNumber", getDisplayOrderNumber(order)); + templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order)); templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); // Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro emailNotificationService.sendEmail( adminMailAddress, - "Nuovo Ordine Ricevuto #" + order.getId() + " - " + order.getCustomer().getLastName(), + "Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(), "order-confirmation", templateData ); } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private String buildOrderDetailsUrl(Order order) { + String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", ""); + return baseUrl + "/ordine/" + order.getId(); + } } diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 569269a..ad965af 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -231,7 +231,7 @@ public class OrderService { 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("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase()); vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); @@ -305,4 +305,12 @@ public class OrderService { } return "stl"; } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } } diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/QrBillService.java index 093739f..430e193 100644 --- a/backend/src/main/java/com/printcalculator/service/QrBillService.java +++ b/backend/src/main/java/com/printcalculator/service/QrBillService.java @@ -51,7 +51,8 @@ public class QrBillService { // Reference // bill.setReference(QRBill.createCreditorReference("...")); // If using QRR - bill.setUnstructuredMessage("Order " + order.getId()); + String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString(); + bill.setUnstructuredMessage("Order " + orderRef); return bill; } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 810d11e..ddaa42e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -39,3 +39,4 @@ spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false} app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} +app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} diff --git a/backend/src/main/resources/templates/email/order-confirmation.html b/backend/src/main/resources/templates/email/order-confirmation.html index fb75eea..708accf 100644 --- a/backend/src/main/resources/templates/email/order-confirmation.html +++ b/backend/src/main/resources/templates/email/order-confirmation.html @@ -55,7 +55,7 @@
-

Grazie per il tuo ordine!

+

Grazie per il tuo ordine #00000000

Ciao Cliente,

@@ -65,7 +65,7 @@ - + @@ -78,6 +78,11 @@
Numero Ordine:#000000000000
Data:
+

+ Clicca qui per i dettagli: + https://tuosito.it/ordine/00000000-0000-0000-0000-000000000000 +

+

Se hai domande o dubbi, non esitare a contattarci.