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