feat(back-end): new db for custom quote requests
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 42s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-12 15:26:58 +01:00
parent bb151ae835
commit 9cbd856ab6
24 changed files with 2142 additions and 219 deletions

558
db.sql
View File

@@ -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), -- 010 h
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 h
(20.00::numeric, null::numeric, 0.50::numeric) -- >20 h
cross join (values (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 010 h
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 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/<orderId>/3d-files/<orderItemId>/<uuid>.stl
stored_filename text NOT NULL, -- es: <uuid>.stl
file_size_bytes bigint CHECK (file_size_bytes >= 0),
mime_type text,
sha256_hex text, -- opzionale, utile anche per dedup interno
material_code text NOT NULL,
color_code text,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
-- Snapshot output
print_time_seconds integer CHECK (print_time_seconds >= 0),
material_grams numeric(12, 2) CHECK (material_grams >= 0),
unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0),
line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_order_items_order
ON order_items (order_id);
-- =========================
-- PAYMENTS (supporta più tentativi / metodi)
-- =========================
CREATE TABLE IF NOT EXISTS payments
(
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
-- riferimento pagamento (molto utile per QR bill / riconciliazione)
payment_reference text,
provider_transaction_id text,
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(),
received_at timestamptz
);
CREATE INDEX IF NOT EXISTS ix_payments_order
ON payments (order_id);
CREATE INDEX IF NOT EXISTS ix_payments_reference
ON payments (payment_reference);
-- =========================
-- CUSTOM QUOTE REQUESTS (preventivo personalizzato, form che hai mostrato)
-- =========================
CREATE TABLE IF NOT EXISTS custom_quote_requests
(
request_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
request_type text NOT NULL, -- es: "PREVENTIVO_PERSONALIZZATO" o come preferisci
customer_type text NOT NULL CHECK (customer_type IN ('PRIVATE', 'COMPANY')),
email text NOT NULL,
phone text,
-- PRIVATE
name text,
-- COMPANY
company_name text,
contact_person text,
message text NOT NULL,
status text NOT NULL CHECK (status IN ('NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_status
ON custom_quote_requests (status);
CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_email
ON custom_quote_requests (lower(email));
-- Allegati della richiesta (max 15 come UI)
CREATE TABLE IF NOT EXISTS custom_quote_request_attachments
(
attachment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
request_id uuid NOT NULL REFERENCES custom_quote_requests (request_id) ON DELETE CASCADE,
original_filename text NOT NULL,
stored_relative_path text NOT NULL, -- es: quote-requests/<requestId>/attachments/<attachmentId>/<uuid>.stl
stored_filename text NOT NULL,
file_size_bytes bigint CHECK (file_size_bytes >= 0),
mime_type text,
sha256_hex text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request
ON custom_quote_request_attachments (request_id);