-- 07-sales-orders.sql -- Sales Orders, customer private notes, lead IP tracking, attachment relations alter table public.leads add column if not exists ip_address text default ''; alter table public.leads add column if not exists ip_country text default ''; create table if not exists public.sales_orders ( id uuid primary key default gen_random_uuid(), customer_id uuid not null references public.customers(id) on delete cascade, lead_id uuid not null references public.leads(id) on delete restrict, order_number text not null default '', private_notes text not null default '', kaution_paid boolean not null default false, rental_paid boolean not null default false, rental_complete boolean not null default false, kaution_paid_at timestamptz, rental_paid_at timestamptz, rental_complete_at timestamptz, daily_subtotal integer not null default 0, weekend_subtotal integer not null default 0, subtotal_eur integer not null default 0, vat_eur integer not null default 0, total_eur integer not null default 0, deposit_eur integer not null default 0, total_days integer not null default 0, weekday_count integer not null default 0, weekend_day_count integer not null default 0, date_from date, date_to date, vehicle_label text not null default '', created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists sales_orders_customer_idx on public.sales_orders (customer_id); create index if not exists sales_orders_lead_idx on public.sales_orders (lead_id); create index if not exists sales_orders_order_number on public.sales_orders (order_number); drop trigger if exists sales_orders_touch on public.sales_orders; create trigger sales_orders_touch before update on public.sales_orders for each row execute function public.tg_touch_updated_at(); alter table public.sales_orders enable row level security; drop policy if exists "sales_orders_admin_select" on public.sales_orders; drop policy if exists "sales_orders_admin_insert" on public.sales_orders; drop policy if exists "sales_orders_admin_update" on public.sales_orders; create policy "sales_orders_admin_select" on public.sales_orders for select to authenticated using (true); create policy "sales_orders_admin_insert" on public.sales_orders for insert to authenticated with check (true); create policy "sales_orders_admin_update" on public.sales_orders for update to authenticated using (true) with check (true); grant select, insert, update on public.sales_orders to authenticated; grant all on public.sales_orders to service_role; create table if not exists public.sales_order_attachments ( id uuid primary key default gen_random_uuid(), sales_order_id uuid not null references public.sales_orders(id) on delete cascade, bucket text not null default 'customer-documents', file_path text not null, file_name text not null default '', mime_type text not null default 'application/octet-stream', kind text not null default 'other' check (kind in ('id_document', 'income_proof', 'other')), created_at timestamptz not null default now() ); create index if not exists sales_order_attachments_so_idx on public.sales_order_attachments (sales_order_id); alter table public.sales_order_attachments enable row level security; drop policy if exists "so_attach_admin_all" on public.sales_order_attachments; create policy "so_attach_admin_all" on public.sales_order_attachments for all to authenticated using (true) with check (true); grant all on public.sales_order_attachments to authenticated; grant all on public.sales_order_attachments to service_role; alter table public.customers add column if not exists private_notes text not null default ''; create unique index if not exists customers_email_lower_unique on public.customers ((lower(email))); alter table public.customer_attachments add column if not exists sales_order_id uuid references public.sales_orders(id) on delete set null; create index if not exists customer_attachments_so_idx on public.customer_attachments (sales_order_id); create or replace function public.qualify_lead(p_lead_id uuid, p_notes text default '') returns public.customers language plpgsql security invoker as $$ declare v_lead public.leads; v_customer public.customers; v_sales_order public.sales_orders; v_user uuid := auth.uid(); v_order_num text; v_year integer; v_count integer; begin select * into v_lead from public.leads where id = p_lead_id for update; if not found then raise exception 'lead % not found', p_lead_id; end if; if v_lead.status = 'qualified' then select * into v_customer from public.customers where lower(email) = lower(v_lead.email) limit 1; return v_customer; end if; update public.leads set status = 'qualified', is_active = false, qualified_at = now(), qualified_by = v_user, admin_notes = coalesce(nullif(p_notes, ''), admin_notes) where id = v_lead.id; insert into public.customers (lead_id, name, email, phone, notes, created_by) values (v_lead.id, v_lead.name, v_lead.email, v_lead.phone, coalesce(p_notes,''), v_user) on conflict ((lower(email))) do update set name = excluded.name, phone = excluded.phone, notes = case when excluded.notes <> '' then excluded.notes else public.customers.notes end, updated_at = now() returning * into v_customer; v_year := extract(year from now())::integer; select coalesce(count(*), 0) + 1 into v_count from public.sales_orders where extract(year from created_at)::integer = v_year; v_order_num := 'SO-' || v_year || '-' || lpad(v_count::text, 4, '0'); insert into public.sales_orders ( customer_id, lead_id, order_number, private_notes, daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count, date_from, date_to, vehicle_label ) values ( v_customer.id, v_lead.id, v_order_num, coalesce(v_lead.admin_notes, ''), coalesce(v_lead.daily_subtotal, 0), coalesce(v_lead.weekend_subtotal, 0), coalesce(v_lead.subtotal_eur, 0), coalesce(v_lead.vat_eur, 0), coalesce(v_lead.total_eur, 0), coalesce(v_lead.deposit_eur, 0), coalesce(v_lead.total_days, 0), coalesce(v_lead.weekday_count, 0), coalesce(v_lead.weekend_day_count, 0), v_lead.date_from, v_lead.date_to, v_lead.vehicle_label ) returning * into v_sales_order; insert into public.customer_attachments (customer_id, lead_id, sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) select v_customer.id, la.lead_id, v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at from public.lead_attachments la where la.lead_id = v_lead.id and not exists ( select 1 from public.customer_attachments ca where ca.customer_id = v_customer.id and ca.file_path = la.file_path ); insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind, created_at) select v_sales_order.id, la.bucket, la.file_path, la.file_name, la.mime_type, la.kind, la.created_at from public.lead_attachments la where la.lead_id = v_lead.id; return v_customer; end; $$; create or replace function public.sales_order_toggle_kaution(p_so_id uuid) returns public.sales_orders language plpgsql security invoker as $$ declare v_so public.sales_orders; begin select * into v_so from public.sales_orders where id = p_so_id for update; if not found then raise exception 'sales order % not found', p_so_id; end if; if v_so.kaution_paid then update public.sales_orders set kaution_paid = false, kaution_paid_at = null, updated_at = now() where id = p_so_id returning * into v_so; else if v_so.rental_complete then raise exception 'cannot set kaution paid after rental is complete'; end if; update public.sales_orders set kaution_paid = true, kaution_paid_at = now(), updated_at = now() where id = p_so_id returning * into v_so; end if; return v_so; end; $$; create or replace function public.sales_order_toggle_rental(p_so_id uuid) returns public.sales_orders language plpgsql security invoker as $$ declare v_so public.sales_orders; begin select * into v_so from public.sales_orders where id = p_so_id for update; if not found then raise exception 'sales order % not found', p_so_id; end if; if v_so.rental_paid then update public.sales_orders set rental_paid = false, rental_paid_at = null, updated_at = now() where id = p_so_id returning * into v_so; else if v_so.kaution_paid = false or v_so.rental_complete then raise exception 'can only set rental paid after kaution is paid and before rental is complete'; end if; update public.sales_orders set rental_paid = true, rental_paid_at = now(), updated_at = now() where id = p_so_id returning * into v_so; end if; return v_so; end; $$; create or replace function public.sales_order_toggle_complete(p_so_id uuid) returns public.sales_orders language plpgsql security invoker as $$ declare v_so public.sales_orders; begin select * into v_so from public.sales_orders where id = p_so_id for update; if not found then raise exception 'sales order % not found', p_so_id; end if; if v_so.rental_complete then update public.sales_orders set rental_complete = false, rental_complete_at = null, updated_at = now() where id = p_so_id returning * into v_so; else if v_so.kaution_paid = false or v_so.rental_paid = false then raise exception 'can only set rental complete after kaution and rental are paid'; end if; update public.sales_orders set rental_complete = true, rental_complete_at = now(), updated_at = now() where id = p_so_id returning * into v_so; end if; return v_so; end; $$; create or replace function public.customer_update_private_notes(p_customer_id uuid, p_notes text) returns void language plpgsql security invoker as $$ begin update public.customers set private_notes = coalesce(p_notes, ''), updated_at = now() where id = p_customer_id; end; $$; create or replace function public.sales_order_update_private_notes(p_so_id uuid, p_notes text) returns void language plpgsql security invoker as $$ begin update public.sales_orders set private_notes = coalesce(p_notes, ''), updated_at = now() where id = p_so_id; end; $$; create or replace function public.sales_order_upload_attachment( p_sales_order_id uuid, p_file_path text, p_file_name text, p_mime_type text, p_kind text default 'other' ) returns uuid language plpgsql security definer as $$ declare v_id uuid; begin insert into public.sales_order_attachments (sales_order_id, bucket, file_path, file_name, mime_type, kind) values (p_sales_order_id, 'customer-documents', p_file_path, p_file_name, p_mime_type, p_kind) returning id into v_id; return v_id; end; $$; create or replace function public.create_lead( p_name text, p_email text, p_phone text default '', p_vehicle_id uuid default null, p_vehicle_label text default '', p_date_from date default null, p_date_to date default null, p_message text default '', p_source text default 'website', p_daily_subtotal integer default 0, p_weekend_subtotal integer default 0, p_subtotal_eur integer default 0, p_vat_eur integer default 0, p_total_eur integer default 0, p_deposit_eur integer default 0, p_total_days integer default 0, p_weekday_count integer default 0, p_weekend_day_count integer default 0, p_ip_address text default '', p_ip_country text default '' ) returns uuid language plpgsql security definer as $$ declare v_lead_id uuid; begin insert into public.leads ( name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count, ip_address, ip_country ) values ( p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to, p_message, p_source, p_daily_subtotal, p_weekend_subtotal, p_subtotal_eur, p_vat_eur, p_total_eur, p_deposit_eur, p_total_days, p_weekday_count, p_weekend_day_count, p_ip_address, p_ip_country ) returning id into v_lead_id; return v_lead_id; end; $$; drop function if exists public.create_lead( text, text, text, uuid, text, date, date, text, text ); drop function if exists public.create_lead( text, text, text, uuid, text, date, date, text, text, integer, integer, integer, integer, integer, integer, integer, integer, integer ); create or replace function public.create_lead( p_name text, p_email text, p_phone text default '', p_vehicle_id uuid default null, p_vehicle_label text default '', p_date_from date default null, p_date_to date default null, p_message text default '', p_source text default 'website', p_daily_subtotal integer default 0, p_weekend_subtotal integer default 0, p_subtotal_eur integer default 0, p_vat_eur integer default 0, p_total_eur integer default 0, p_deposit_eur integer default 0, p_total_days integer default 0, p_weekday_count integer default 0, p_weekend_day_count integer default 0, p_ip_address text default '', p_ip_country text default '' ) returns uuid language plpgsql security definer as $$ declare v_lead_id uuid; begin insert into public.leads ( name, email, phone, vehicle_id, vehicle_label, date_from, date_to, message, source, daily_subtotal, weekend_subtotal, subtotal_eur, vat_eur, total_eur, deposit_eur, total_days, weekday_count, weekend_day_count, ip_address, ip_country ) values ( p_name, p_email, p_phone, p_vehicle_id, p_vehicle_label, p_date_from, p_date_to, p_message, p_source, p_daily_subtotal, p_weekend_subtotal, p_subtotal_eur, p_vat_eur, p_total_eur, p_deposit_eur, p_total_days, p_weekday_count, p_weekend_day_count, p_ip_address, p_ip_country ) returning id into v_lead_id; return v_lead_id; end; $$; grant execute on function public.sales_order_toggle_kaution(uuid) to authenticated; grant execute on function public.sales_order_toggle_rental(uuid) to authenticated; grant execute on function public.sales_order_toggle_complete(uuid) to authenticated; grant execute on function public.customer_update_private_notes(uuid, text) to authenticated; grant execute on function public.sales_order_update_private_notes(uuid, text) to authenticated; grant execute on function public.sales_order_upload_attachment(uuid, text, text, text, text) to authenticated; grant execute on function public.create_lead( text, text, text, uuid, text, date, date, text, text, integer, integer, integer, integer, integer, integer, integer, integer, integer, text, text ) to anon, authenticated, service_role; notify pgrst, 'reload schema';