create table printer_machine ( printer_machine_id bigserial primary key, printer_display_name text not null unique, build_volume_x_mm integer not null check (build_volume_x_mm > 0), build_volume_y_mm integer not null check (build_volume_y_mm > 0), build_volume_z_mm integer not null check (build_volume_z_mm > 0), power_watts integer not null check (power_watts > 0), fleet_weight numeric(6, 3) not null default 1.000, is_active boolean not null default true, created_at timestamptz not null default now() ); create view printer_fleet_current as 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, 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 from printer_machine where is_active = true; create table filament_material_type ( filament_material_type_id bigserial primary key, material_code text not null unique, -- PLA, PETG, TPU, ASA... is_flexible boolean not null default false, -- sì/no is_technical boolean not null default false, -- sì/no technical_type_label text -- es: "alta temperatura", "rinforzato", ecc. ); create table filament_variant ( filament_variant_id bigserial primary key, filament_material_type_id bigint not null references filament_material_type (filament_material_type_id), variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" color_name text not null, -- Nero, Bianco, ecc. color_hex text, finish_type text not null default 'GLOSSY' check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), brand text, is_matte boolean not null default false, is_special boolean not null default false, cost_chf_per_kg numeric(10, 2) not null, -- Stock espresso in rotoli anche frazionati stock_spools numeric(6, 3) not null default 0.000, spool_net_kg numeric(6, 3) not null default 1.000, is_active boolean not null default true, created_at timestamptz not null default now(), unique (filament_material_type_id, variant_display_name) ); create view filament_variant_stock_kg as select filament_variant_id, stock_spools, spool_net_kg, (stock_spools * spool_net_kg) as stock_kg from filament_variant; create table printer_machine_profile ( printer_machine_profile_id bigserial primary key, printer_machine_id bigint not null references printer_machine (printer_machine_id) on delete cascade, nozzle_diameter_mm numeric(4, 2) not null check (nozzle_diameter_mm > 0), orca_machine_profile_name text not null, is_default boolean not null default false, is_active boolean not null default true, unique (printer_machine_id, nozzle_diameter_mm) ); create table material_orca_profile_map ( material_orca_profile_map_id bigserial primary key, printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, filament_material_type_id bigint not null references filament_material_type (filament_material_type_id), orca_filament_profile_name text not null, is_active boolean not null default true, unique (printer_machine_profile_id, filament_material_type_id) ); create table filament_variant_orca_override ( filament_variant_orca_override_id bigserial primary key, filament_variant_id bigint not null references filament_variant (filament_variant_id) on delete cascade, printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, orca_filament_profile_name text not null, is_active boolean not null default true, unique (filament_variant_id, printer_machine_profile_id) ); create table pricing_policy ( pricing_policy_id bigserial primary key, policy_name text not null, -- es: "2026 Q1", "Default", ecc. -- validità temporale (consiglio: valid_to esclusiva) valid_from timestamptz not null, valid_to timestamptz, electricity_cost_chf_per_kwh numeric(10, 6) not null, markup_percent numeric(6, 3) not null default 20.000, fixed_job_fee_chf numeric(10, 2) not null default 0.00, -- "costo fisso" nozzle_change_base_fee_chf numeric(10, 2) not null default 0.00, -- base cambio ugello, se vuoi cad_cost_chf_per_hour numeric(10, 2) not null default 0.00, is_active boolean not null default true, created_at timestamptz not null default now() ); create table pricing_policy_machine_hour_tier ( pricing_policy_machine_hour_tier_id bigserial primary key, pricing_policy_id bigint not null references pricing_policy (pricing_policy_id), tier_start_hours numeric(10, 2) not null, tier_end_hours numeric(10, 2), -- null = infinito machine_cost_chf_per_hour numeric(10, 2) not null, constraint chk_tier_start_non_negative check (tier_start_hours >= 0), constraint chk_tier_end_gt_start check (tier_end_hours is null or tier_end_hours > tier_start_hours) ); create index idx_pricing_policy_validity on pricing_policy (valid_from, valid_to); create index idx_pricing_tier_lookup on pricing_policy_machine_hour_tier (pricing_policy_id, tier_start_hours); create table nozzle_option ( nozzle_option_id bigserial primary key, nozzle_diameter_mm numeric(4, 2) not null unique, -- 0.4, 0.6, 0.8... owned_quantity integer not null default 0 check (owned_quantity >= 0), -- extra costo specifico oltre ad eventuale base fee della pricing_policy extra_nozzle_change_fee_chf numeric(10, 2) not null default 0.00, is_active boolean not null default true, created_at timestamptz not null default now() ); create table layer_height_option ( layer_height_option_id bigserial primary key, layer_height_mm numeric(5, 3) not null unique, -- 0.12, 0.20, 0.28... -- opzionale: moltiplicatore costo/tempo (es: 0.12 costa di più) time_multiplier numeric(6, 3) not null default 1.000, is_active boolean not null default true ); create table layer_height_profile ( layer_height_profile_id bigserial primary key, profile_name text not null unique, -- "Standard", "Fine", ecc. min_layer_height_mm numeric(5, 3) not null, max_layer_height_mm numeric(5, 3) not null, default_layer_height_mm numeric(5, 3) not null, time_multiplier numeric(6, 3) not null default 1.000, constraint chk_layer_range check (max_layer_height_mm >= min_layer_height_mm) ); begin; set timezone = 'Europe/Zurich'; -- ========================================================= -- 0) (Solo se non esiste) tabella infill_pattern + seed -- ========================================================= -- Se la tabella esiste già, commenta questo blocco. create table if not exists infill_pattern ( infill_pattern_id bigserial primary key, pattern_code text not null unique, -- es: grid, gyroid display_name text not null, is_active boolean not null default true ); insert into infill_pattern (pattern_code, display_name, is_active) values ('grid', 'Grid', true), ('gyroid', 'Gyroid', true) on conflict (pattern_code) do update set display_name = excluded.display_name, is_active = excluded.is_active; -- ========================================================= -- 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) 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 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 ) 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; -- ========================================================= -- 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) on conflict (printer_display_name) do update 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; -- ========================================================= -- 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), ('PLA TOUGH', false, false, null), ('PETG', false, false, null), ('TPU', true, false, null), ('PC', false, true, 'engineering'), ('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, technical_type_label = excluded.technical_type_label; -- ========================================================= -- 4) Filament variants (PLA colori) - costi da Excel -- Excel: PLA = 18 CHF/kg, TPU = 42 CHF/kg (non inserito perché quantità non chiara) -- Stock in "rotoli" (3 = 3 kg se spool_net_kg=1) -- ========================================================= -- 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, color_hex, finish_type, brand, 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.color_hex, v.finish_type, null::text as brand, 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', '#F5F5F5', 'GLOSSY', false, false, 3.000::numeric), ('PLA Nero', 'Nero', '#1A1A1A', 'GLOSSY', false, false, 3.000::numeric), ('PLA Blu', 'Blu', '#1976D2', 'GLOSSY', false, false, 1.000::numeric), ('PLA Arancione', 'Arancione', '#FFA726', 'GLOSSY', false, false, 1.000::numeric), ('PLA Grigio', 'Grigio', '#BDBDBD', 'GLOSSY', false, false, 1.000::numeric), ('PLA Grigio Scuro', 'Grigio scuro', '#424242', 'MATTE', true, false, 1.000::numeric), ('PLA Grigio Chiaro', 'Grigio chiaro', '#D6D6D6', 'MATTE', true, false, 1.000::numeric), ('PLA Viola', 'Viola', '#7B1FA2', 'GLOSSY', false, false, 1.000::numeric)) as v(variant_display_name, color_name, color_hex, finish_type, is_matte, is_special, stock_spools) on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, color_hex = excluded.color_hex, finish_type = excluded.finish_type, brand = excluded.brand, 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; -- Varianti base per materiali principali del calcolatore with mat as (select filament_material_type_id from filament_material_type where material_code = 'PLA TOUGH') insert into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) select mat.filament_material_type_id, 'PLA Tough Nero', 'Nero', '#1A1A1A', 'GLOSSY', 'Bambu', false, false, 18.00, 1.000, 1.000, true from mat on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, color_hex = excluded.color_hex, finish_type = excluded.finish_type, brand = excluded.brand, 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; with mat as (select filament_material_type_id from filament_material_type where material_code = 'PETG') insert into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) select mat.filament_material_type_id, 'PETG Nero', 'Nero', '#1A1A1A', 'GLOSSY', 'Bambu', false, false, 24.00, 1.000, 1.000, true from mat on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, color_hex = excluded.color_hex, finish_type = excluded.finish_type, brand = excluded.brand, 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; with mat as (select filament_material_type_id from filament_material_type where material_code = 'TPU') insert into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) select mat.filament_material_type_id, 'TPU Nero', 'Nero', '#1A1A1A', 'GLOSSY', 'Bambu', false, false, 42.00, 1.000, 1.000, true from mat on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, color_hex = excluded.color_hex, finish_type = excluded.finish_type, brand = excluded.brand, 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; with mat as (select filament_material_type_id from filament_material_type where material_code = 'PC') insert into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) select mat.filament_material_type_id, 'PC Naturale', 'Naturale', '#D9D9D9', 'TRANSLUCENT', 'Generic', false, true, 48.00, 1.000, 1.000, true from mat on conflict (filament_material_type_id, variant_display_name) do update set color_name = excluded.color_name, color_hex = excluded.color_hex, finish_type = excluded.finish_type, brand = excluded.brand, 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; -- ========================================================= -- 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) on conflict (nozzle_diameter_mm) do update set owned_quantity = excluded.owned_quantity, extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf, is_active = excluded.is_active; -- ========================================================= -- 5b) Orca machine/material mapping (data-driven) -- ========================================================= with a1 as (select printer_machine_id from printer_machine where printer_display_name = 'BambuLab A1') insert into printer_machine_profile (printer_machine_id, nozzle_diameter_mm, orca_machine_profile_name, is_default, is_active) select a1.printer_machine_id, v.nozzle_diameter_mm, v.profile_name, v.is_default, true from a1 cross join (values (0.40::numeric, 'Bambu Lab A1 0.4 nozzle', true), (0.20::numeric, 'Bambu Lab A1 0.2 nozzle', false), (0.60::numeric, 'Bambu Lab A1 0.6 nozzle', false), (0.80::numeric, 'Bambu Lab A1 0.8 nozzle', false)) as v(nozzle_diameter_mm, profile_name, is_default) on conflict (printer_machine_id, nozzle_diameter_mm) do update set orca_machine_profile_name = excluded.orca_machine_profile_name, is_default = excluded.is_default, is_active = excluded.is_active; with p as (select printer_machine_profile_id from printer_machine_profile pmp join printer_machine pm on pm.printer_machine_id = pmp.printer_machine_id where pm.printer_display_name = 'BambuLab A1' and pmp.nozzle_diameter_mm = 0.40::numeric), m as (select filament_material_type_id, material_code from filament_material_type where material_code in ('PLA', 'PLA TOUGH', 'PETG', 'TPU', 'PC')) insert into material_orca_profile_map (printer_machine_profile_id, filament_material_type_id, orca_filament_profile_name, is_active) select p.printer_machine_profile_id, m.filament_material_type_id, case m.material_code when 'PLA' then 'Bambu PLA Basic @BBL A1' when 'PLA TOUGH' then 'Bambu PLA Tough @BBL A1' when 'PETG' then 'Bambu PETG Basic @BBL A1' when 'TPU' then 'Bambu TPU 95A @BBL A1' when 'PC' then 'Generic PC @BBL A1' end, true from p cross join m on conflict (printer_machine_profile_id, filament_material_type_id) do update set orca_filament_profile_name = excluded.orca_filament_profile_name, 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) on conflict (layer_height_mm) do update 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', 'CAD_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, source_request_id uuid, cad_hours numeric(10, 2), cad_hourly_rate_chf numeric(10, 2), 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); CREATE INDEX IF NOT EXISTS ix_quote_sessions_source_request ON quote_sessions (source_request_id); ALTER TABLE quote_sessions ADD COLUMN IF NOT EXISTS source_request_id uuid; ALTER TABLE quote_sessions ADD COLUMN IF NOT EXISTS cad_hours numeric(10, 2); ALTER TABLE quote_sessions ADD COLUMN IF NOT EXISTS cad_hourly_rate_chf numeric(10, 2); ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS quote_sessions_status_check; ALTER TABLE quote_sessions ADD CONSTRAINT quote_sessions_status_check CHECK (status IN ('ACTIVE', 'CAD_ACTIVE', 'EXPIRED', 'CONVERTED')); -- ========================= -- 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 filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), material_code text, nozzle_diameter_mm numeric(5, 2), layer_height_mm numeric(6, 3), infill_pattern text, infill_percent integer CHECK (infill_percent BETWEEN 0 AND 100), supports_enabled boolean, -- 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); ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS material_code text; ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS nozzle_diameter_mm numeric(5, 2); ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS layer_height_mm numeric(6, 3); ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS infill_pattern text; ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS infill_percent integer; ALTER TABLE quote_line_items ADD COLUMN IF NOT EXISTS supports_enabled boolean; -- 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), source_request_id uuid, 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, preferred_language char(2) NOT NULL DEFAULT 'it', -- 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, is_cad_order boolean NOT NULL DEFAULT false, cad_hours numeric(10, 2), cad_hourly_rate_chf numeric(10, 2), cad_total_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)); CREATE INDEX IF NOT EXISTS ix_orders_source_request ON orders (source_request_id); ALTER TABLE orders ADD COLUMN IF NOT EXISTS source_request_id uuid; ALTER TABLE orders ADD COLUMN IF NOT EXISTS is_cad_order boolean NOT NULL DEFAULT false; ALTER TABLE orders ADD COLUMN IF NOT EXISTS cad_hours numeric(10, 2); ALTER TABLE orders ADD COLUMN IF NOT EXISTS cad_hourly_rate_chf numeric(10, 2); ALTER TABLE orders ADD COLUMN IF NOT EXISTS cad_total_chf numeric(12, 2) NOT NULL DEFAULT 0.00; -- ========================= -- 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, filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), 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), bounding_box_x_mm numeric(10, 3), bounding_box_y_mm numeric(10, 3), bounding_box_z_mm numeric(10, 3), 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', 'TWINT', 'OTHER')), status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', '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(), reported_at timestamptz, 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); CREATE TABLE IF NOT EXISTS media_asset ( media_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), original_filename text NOT NULL, storage_key text NOT NULL UNIQUE, mime_type text NOT NULL, file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), sha256_hex text NOT NULL, width_px integer, height_px integer, status text NOT NULL CHECK (status IN ('UPLOADED', 'PROCESSING', 'READY', 'FAILED', 'ARCHIVED')), visibility text NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')), title text, alt_text text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS ix_media_asset_status_visibility_created_at ON media_asset (status, visibility, created_at DESC); CREATE TABLE IF NOT EXISTS media_variant ( media_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, variant_name text NOT NULL, format text NOT NULL CHECK (format IN ('ORIGINAL', 'JPEG', 'WEBP', 'AVIF')), storage_key text NOT NULL UNIQUE, mime_type text NOT NULL, width_px integer NOT NULL, height_px integer NOT NULL, file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), is_generated boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT uq_media_variant_asset_name_format UNIQUE (media_asset_id, variant_name, format) ); CREATE INDEX IF NOT EXISTS ix_media_variant_asset ON media_variant (media_asset_id); CREATE TABLE IF NOT EXISTS media_usage ( media_usage_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), usage_type text NOT NULL, usage_key text NOT NULL, owner_id uuid, media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, sort_order integer NOT NULL DEFAULT 0, is_primary boolean NOT NULL DEFAULT false, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS ix_media_usage_scope ON media_usage (usage_type, usage_key, is_active, sort_order); ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; ALTER TABLE quote_sessions ADD CONSTRAINT fk_quote_sessions_source_request FOREIGN KEY (source_request_id) REFERENCES custom_quote_requests (request_id); ALTER TABLE orders DROP CONSTRAINT IF EXISTS fk_orders_source_request; ALTER TABLE orders ADD CONSTRAINT fk_orders_source_request FOREIGN KEY (source_request_id) REFERENCES custom_quote_requests (request_id);