diff --git a/docs/sql/add-source-orders-to-order-items.sql b/docs/sql/add-source-orders-to-order-items.sql new file mode 100644 index 0000000..976d0f7 --- /dev/null +++ b/docs/sql/add-source-orders-to-order-items.sql @@ -0,0 +1,199 @@ +-- Migration: add source_orders items to get_delivery_invitation_by_token +-- This replaces ONLY the orderItems building section for the group path. +-- Apply AFTER the base function is restored. + +-- Step 1: First restore the original function (run restore-rpc-original.sql if needed) +-- Step 2: Then run this migration + +CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $$ +DECLARE + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_state text; + v_order_number text; + v_customer_name text; + v_customer_phone text; + v_order_items jsonb; + v_order_numbers jsonb; + v_now timestamptz := timezone('utc', now()); +BEGIN + IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN + RAISE EXCEPTION 'token is required'; + END IF; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + + SELECT * + INTO v_invitation + FROM public.delivery_invitations + WHERE token_hash = v_token_hash; + + IF NOT found THEN + RAISE EXCEPTION 'Invitation not found'; + END IF; + + IF v_invitation.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; + + IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; + + IF v_invitation.order_group_id IS NOT NULL THEN + SELECT * + INTO v_group + FROM public.order_groups + WHERE id = v_invitation.order_group_id; + + IF NOT found THEN + RAISE EXCEPTION 'Order group not found'; + END IF; + + v_state := CASE + WHEN v_group.delivery_status = 'agreed' THEN 'agreed' + WHEN v_group.delivery_status = 'delivered' THEN 'delivered' + WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state + ELSE 'default' + END; + + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, + last_accessed_at = v_now + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; + + v_order_number := COALESCE( + NULLIF(v_invitation.order_number, ''), + to_jsonb(v_group.order_numbers) ->> 0, + NULLIF(v_group.group_key, '') + ); + v_customer_name := COALESCE( + NULLIF(v_group.customer_name, ''), + NULLIF(v_invitation.customer_name, '') + ); + v_customer_phone := COALESCE( + NULLIF(v_group.customer_phone, ''), + NULLIF(v_group.customer_phone_normalized, ''), + NULLIF(v_invitation.customer_phone, '') + ); + + -- Build orderItems: use source_orders for real product lines if available, + -- otherwise fall back to invoice numbers from order_numbers. + v_order_items := CASE + WHEN v_group.source_orders IS NOT NULL + AND jsonb_typeof(v_group.source_orders) = 'array' + AND jsonb_array_length(v_group.source_orders) > 0 + THEN COALESCE( + (SELECT jsonb_agg( + jsonb_build_object( + 'name', COALESCE(src ->> 'nom', src ->> 'name', ''), + 'quantity', '', + 'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb) + ) + ) FROM jsonb_array_elements(v_group.source_orders) AS src), + '[]'::jsonb + ) + ELSE COALESCE( + (SELECT jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')) + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers) + ELSE '[]'::jsonb + END + ) AS order_number), + '[]'::jsonb + ) + END; + + RETURN jsonb_build_object( + 'ok', TRUE, + 'invitation', jsonb_build_object( + 'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text, + 'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', v_order_number, + 'customerName', v_customer_name, + 'customerPhone', v_customer_phone, + 'orderItems', v_order_items, + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', NULL, + 'deliveryAgreementStatus', NULL + ) + ); + END IF; + + SELECT id, order_number, status, delivery_agreement_status, customer + INTO v_order + FROM public.orders + WHERE id = v_invitation.order_id; + + IF NOT found THEN + RAISE EXCEPTION 'Order not found'; + END IF; + + v_state := CASE v_order.status + WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice' + WHEN 'Ожидает согласования доставки' THEN 'opened' + WHEN 'Напоминание отправлено' THEN 'reminder_sent' + WHEN 'Переход отправлен' THEN 'reminder_sent' + WHEN 'Передан логисту' THEN 'transferred_to_logistics' + WHEN 'Платное хранение' THEN 'paid_storage' + WHEN 'Доставлен' THEN 'delivered' + WHEN 'Доставка согласована' THEN 'agreed' + ELSE 'default' + END; + + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, + last_accessed_at = v_now + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; + + v_order_items := CASE + WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items' + ELSE '[]'::jsonb + END; + + RETURN jsonb_build_object( + 'ok', TRUE, + 'invitation', jsonb_build_object( + 'orderId', v_invitation.order_id::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')), + 'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')), + 'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')), + 'orderItems', v_order_items, + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', v_order.status, + 'deliveryAgreementStatus', v_order.delivery_agreement_status + ) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public; +GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated; diff --git a/docs/sql/public-delivery-choice-rpc.sql b/docs/sql/public-delivery-choice-rpc.sql index 1552f91..c88e936 100644 --- a/docs/sql/public-delivery-choice-rpc.sql +++ b/docs/sql/public-delivery-choice-rpc.sql @@ -1,12 +1,17 @@ -create extension if not exists pgcrypto; +-- Migration: add source_orders items to get_delivery_invitation_by_token +-- This replaces ONLY the orderItems building section for the group path. +-- Apply AFTER the base function is restored. -create or replace function public.get_delivery_invitation_by_token(p_token text) -returns jsonb -language plpgsql -security definer -set search_path = public, extensions -as $$ -declare +-- Step 1: First restore the original function (run restore-rpc-original.sql if needed) +-- Step 2: Then run this migration + +CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $$ +DECLARE v_invitation public.delivery_invitations%rowtype; v_group public.order_groups%rowtype; v_order record; @@ -18,291 +23,275 @@ declare v_order_items jsonb; v_order_numbers jsonb; v_now timestamptz := timezone('utc', now()); -begin - if nullif(trim(coalesce(p_token, '')), '') is null then - raise exception 'token is required'; - end if; +BEGIN + IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN + RAISE EXCEPTION 'token is required'; + END IF; v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); - select * - into v_invitation - from public.delivery_invitations - where token_hash = v_token_hash; + SELECT * + INTO v_invitation + FROM public.delivery_invitations + WHERE token_hash = v_token_hash; - if not found then - raise exception 'Invitation not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Invitation not found'; + END IF; - if v_invitation.revoked_at is not null then - raise exception 'Invitation expired'; - end if; + IF v_invitation.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; - if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then - raise exception 'Invitation expired'; - end if; + IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; - if v_invitation.order_group_id is not null then - select * - into v_group - from public.order_groups - where id = v_invitation.order_group_id; + IF v_invitation.order_group_id IS NOT NULL THEN + SELECT * + INTO v_group + FROM public.order_groups + WHERE id = v_invitation.order_group_id; - if not found then - raise exception 'Order group not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Order group not found'; + END IF; - v_state := case - when v_group.delivery_status = 'agreed' then 'agreed' - when v_group.delivery_status = 'delivered' then 'delivered' - when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state - else 'default' - end; + v_state := CASE + WHEN v_group.delivery_status = 'agreed' THEN 'agreed' + WHEN v_group.delivery_status = 'delivered' THEN 'delivered' + WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state + ELSE 'default' + END; - update public.delivery_invitations - set - opened_at = case - when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now - else opened_at - end, - access_count = coalesce(access_count, 0) + 1, + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, last_accessed_at = v_now - where id = v_invitation.id - returning * into v_invitation; + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; - v_order_number := coalesce( - nullif(v_invitation.order_number, ''), + v_order_number := COALESCE( + NULLIF(v_invitation.order_number, ''), to_jsonb(v_group.order_numbers) ->> 0, - nullif(v_group.group_key, '') + NULLIF(v_group.group_key, '') ); - v_customer_name := coalesce( - nullif(v_group.customer_name, ''), - nullif(v_invitation.customer_name, '') + v_customer_name := COALESCE( + NULLIF(v_group.customer_name, ''), + NULLIF(v_invitation.customer_name, '') ); - v_customer_phone := coalesce( - nullif(v_group.customer_phone, ''), - nullif(v_group.customer_phone_normalized, ''), - nullif(v_invitation.customer_phone, '') + v_customer_phone := COALESCE( + NULLIF(v_group.customer_phone, ''), + NULLIF(v_group.customer_phone_normalized, ''), + NULLIF(v_invitation.customer_phone, '') ); - -- Build orderItems from source_orders if available (real product lines), - -- otherwise fall back to invoice numbers from order_numbers. - v_order_items := case - when v_group.source_orders is not null - and jsonb_typeof(v_group.source_orders) = 'array' - and jsonb_array_length(v_group.source_orders) > 0 - then - coalesce( - (select jsonb_agg( - jsonb_build_object( - 'name', coalesce(src ->> 'nom', src ->> 'name', onum), - 'quantity', '', - 'items', coalesce( - src -> 'orderList', - src -> 'items', - '[]'::jsonb - ) - ) - ) - from jsonb_array_elements(v_group.source_orders) with ordinality as t(src, idx) - cross join lateral ( - select coalesce( - nullif(v_group.order_numbers[idx], ''), - src ->> 'nom', - src ->> 'name', - '' - ) as onum - ) as names - ), - '[]'::jsonb - ) - else - coalesce( - (select jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')) - from jsonb_array_elements_text( - case - when jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' then to_jsonb(v_group.order_numbers) - else '[]'::jsonb - end - ) as order_number), - '[]'::jsonb - ) - end; - return jsonb_build_object( - 'ok', true, + -- Build orderItems: use source_orders for real product lines if available, + -- otherwise fall back to invoice numbers from order_numbers. + v_order_items := CASE + WHEN v_group.source_orders IS NOT NULL + AND jsonb_typeof(v_group.source_orders) = 'array' + AND jsonb_array_length(v_group.source_orders) > 0 + THEN COALESCE( + (SELECT jsonb_agg( + jsonb_build_object( + 'name', COALESCE(src ->> 'nom', src ->> 'name', ''), + 'quantity', '', + 'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb) + ) + ) FROM jsonb_array_elements(v_group.source_orders) AS src), + '[]'::jsonb + ) + ELSE COALESCE( + (SELECT jsonb_agg(jsonb_build_object('name', onum, 'quantity', '')) + FROM unnest(v_group.order_numbers) AS onum + WHERE onum IS NOT NULL AND onum <> ''), + '[]'::jsonb + ) + END; + + RETURN jsonb_build_object( + 'ok', TRUE, 'invitation', jsonb_build_object( - 'orderId', coalesce(v_invitation.order_group_id, v_group.id)::text, - 'orderGroupId', coalesce(v_invitation.order_group_id, v_group.id)::text, + 'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text, + 'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text, 'state', v_state, 'token', p_token, 'orderNumber', v_order_number, 'customerName', v_customer_name, 'customerPhone', v_customer_phone, 'orderItems', v_order_items, - 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), 'deliveryDate', v_invitation.delivery_date, 'deliveryTime', v_invitation.delivery_time, - 'orderStatus', null, - 'deliveryAgreementStatus', null + 'orderStatus', NULL, + 'deliveryAgreementStatus', NULL ) ); - end if; + END IF; - select id, order_number, status, delivery_agreement_status, customer - into v_order - from public.orders - where id = v_invitation.order_id; + SELECT id, order_number, status, delivery_agreement_status, customer + INTO v_order + FROM public.orders + WHERE id = v_invitation.order_id; - if not found then - raise exception 'Order not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Order not found'; + END IF; - v_state := case v_order.status - when 'Ожидает ответа клиента' then 'awaiting_choice' - when 'Ожидает согласования доставки' then 'opened' - when 'Напоминание отправлено' then 'reminder_sent' - when 'Переход отправлен' then 'reminder_sent' - when 'Передан логисту' then 'transferred_to_logistics' - when 'Платное хранение' then 'paid_storage' - when 'Доставлен' then 'delivered' - when 'Доставка согласована' then 'agreed' - else 'default' - end; + v_state := CASE v_order.status + WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice' + WHEN 'Ожидает согласования доставки' THEN 'opened' + WHEN 'Напоминание отправлено' THEN 'reminder_sent' + WHEN 'Переход отправлен' THEN 'reminder_sent' + WHEN 'Передан логисту' THEN 'transferred_to_logistics' + WHEN 'Платное хранение' THEN 'paid_storage' + WHEN 'Доставлен' THEN 'delivered' + WHEN 'Доставка согласована' THEN 'agreed' + ELSE 'default' + END; - update public.delivery_invitations - set - opened_at = case - when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now - else opened_at - end, - access_count = coalesce(access_count, 0) + 1, + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, last_accessed_at = v_now - where id = v_invitation.id - returning * into v_invitation; + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; - v_order_items := case - when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items' - else '[]'::jsonb - end; + v_order_items := CASE + WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items' + ELSE '[]'::jsonb + END; - return jsonb_build_object( - 'ok', true, + RETURN jsonb_build_object( + 'ok', TRUE, 'invitation', jsonb_build_object( 'orderId', v_invitation.order_id::text, 'state', v_state, 'token', p_token, - 'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')), - 'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), - 'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), + 'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')), + 'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')), + 'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')), 'orderItems', v_order_items, - 'availableSlots', coalesce(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), 'deliveryDate', v_invitation.delivery_date, 'deliveryTime', v_invitation.delivery_time, 'orderStatus', v_order.status, 'deliveryAgreementStatus', v_order.delivery_agreement_status ) ); -end; +END; $$; -create or replace function public.confirm_delivery_choice_by_token( +REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public; +GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated; + +CREATE OR REPLACE FUNCTION public.confirm_delivery_choice_by_token( p_token text, p_delivery_date date, p_delivery_time text ) -returns jsonb -language plpgsql -security definer -set search_path = public, extensions -as $$ -declare +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $$ +DECLARE v_invitation public.delivery_invitations%rowtype; v_group public.order_groups%rowtype; v_order record; v_token_hash text; v_slot_label text; v_now timestamptz := timezone('utc', now()); -begin - if nullif(trim(coalesce(p_token, '')), '') is null then - raise exception 'token is required'; - end if; +BEGIN + IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN + RAISE EXCEPTION 'token is required'; + END IF; - if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then - raise exception 'Selected slot is not available'; - end if; + IF p_delivery_date IS NULL OR nullif(trim(coalesce(p_delivery_time, '')), '') IS NULL THEN + RAISE EXCEPTION 'Selected slot is not available'; + END IF; v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); - select * - into v_invitation - from public.delivery_invitations - where token_hash = v_token_hash - for update; + SELECT * + INTO v_invitation + FROM public.delivery_invitations + WHERE token_hash = v_token_hash + FOR UPDATE; - if not found then - raise exception 'Invitation not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Invitation not found'; + END IF; - if v_invitation.revoked_at is not null then - raise exception 'Invitation expired'; - end if; + IF v_invitation.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; - if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then - raise exception 'Invitation expired'; - end if; + IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; - if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then - raise exception 'Invitation is no longer active'; - end if; + IF v_invitation.state NOT IN ('awaiting_choice', 'opened', 'reminder_sent') THEN + RAISE EXCEPTION 'Invitation is no longer active'; + END IF; - if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then - raise exception 'Selected slot is not available'; - end if; + IF cardinality(v_invitation.available_slots) > 0 AND NOT (v_slot_label = ANY(v_invitation.available_slots)) THEN + RAISE EXCEPTION 'Selected slot is not available'; + END IF; - if v_invitation.order_group_id is not null then - select * - into v_group - from public.order_groups - where id = v_invitation.order_group_id - for update; + IF v_invitation.order_group_id IS NOT NULL THEN + SELECT * + INTO v_group + FROM public.order_groups + WHERE id = v_invitation.order_group_id + FOR UPDATE; - if not found then - raise exception 'Order group not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Order group not found'; + END IF; - if v_group.delivery_status <> 'pending_confirmation' then - raise exception 'Invitation is no longer active'; - end if; + IF v_group.delivery_status <> 'pending_confirmation' THEN + RAISE EXCEPTION 'Invitation is no longer active'; + END IF; - update public.delivery_invitations - set + UPDATE public.delivery_invitations + SET state = 'agreed', delivery_date = p_delivery_date, delivery_time = trim(p_delivery_time), confirmed_at = v_now, - access_count = coalesce(access_count, 0) + 1, + access_count = COALESCE(access_count, 0) + 1, last_accessed_at = v_now - where id = v_invitation.id; + WHERE id = v_invitation.id; - update public.order_groups - set + UPDATE public.order_groups + SET delivery_status = 'agreed', delivery_date = p_delivery_date, delivery_time = trim(p_delivery_time), notification_status = 'confirmed', updated_at = v_now - where id = v_group.id; + WHERE id = v_group.id; - insert into public.integration_events ( + INSERT INTO public.integration_events ( order_id, event_type, direction, status, payload ) - values ( - null, + VALUES ( + NULL, 'delivery_choice_confirmed', 'inbound', 'success', @@ -314,66 +303,66 @@ begin ) ); - return jsonb_build_object( - 'ok', true, + RETURN jsonb_build_object( + 'ok', TRUE, 'orderGroupId', v_group.id, 'deliveryStatus', 'agreed' ); - end if; + END IF; - select id, status, delivery_agreement_status - into v_order - from public.orders - where id = v_invitation.order_id - for update; + SELECT id, status, delivery_agreement_status + INTO v_order + FROM public.orders + WHERE id = v_invitation.order_id + FOR UPDATE; - if not found then - raise exception 'Order not found'; - end if; + IF NOT found THEN + RAISE EXCEPTION 'Order not found'; + END IF; - if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then - raise exception 'Invitation is no longer active'; - end if; + IF v_order.status NOT IN ('Ожидает ответа клиента', 'Ожидает согласования доставки') THEN + RAISE EXCEPTION 'Invitation is no longer active'; + END IF; - update public.delivery_invitations - set + UPDATE public.delivery_invitations + SET state = 'agreed', delivery_date = p_delivery_date, delivery_time = trim(p_delivery_time), confirmed_at = v_now, - access_count = coalesce(access_count, 0) + 1, + access_count = COALESCE(access_count, 0) + 1, last_accessed_at = v_now - where id = v_invitation.id; + WHERE id = v_invitation.id; - update public.orders - set + UPDATE public.orders + SET status = 'Доставка согласована', delivery_agreement_status = 'Подтверждено клиентом' - where id = v_order.id; + WHERE id = v_order.id; - insert into public.delivery_slots ( + INSERT INTO public.delivery_slots ( order_id, delivery_date, delivery_time, logistician_id, status ) - values ( + VALUES ( v_order.id, p_delivery_date, trim(p_delivery_time), - null, + NULL, 'confirmed_by_client' ); - insert into public.order_history ( + INSERT INTO public.order_history ( order_id, action, old_status, new_status, metadata ) - values ( + VALUES ( v_order.id, 'Подтверждение выбора доставки клиентом', v_order.status, @@ -386,14 +375,14 @@ begin ) ); - insert into public.integration_events ( + INSERT INTO public.integration_events ( order_id, event_type, direction, status, payload ) - values ( + VALUES ( v_order.id, 'delivery_choice_confirmed', 'inbound', @@ -404,17 +393,14 @@ begin ) ); - return jsonb_build_object( - 'ok', true, + RETURN jsonb_build_object( + 'ok', TRUE, 'orderId', v_order.id, 'status', 'Доставка согласована', 'deliveryAgreementStatus', 'Подтверждено клиентом' ); -end; +END; $$; -revoke all on function public.get_delivery_invitation_by_token(text) from public; -grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated; - -revoke all on function public.confirm_delivery_choice_by_token(text, date, text) from public; -grant execute on function public.confirm_delivery_choice_by_token(text, date, text) to anon, authenticated; +REVOKE ALL ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) FROM public; +GRANT EXECUTE ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) TO anon, authenticated; diff --git a/docs/sql/restore-rpc-original.sql b/docs/sql/restore-rpc-original.sql new file mode 100644 index 0000000..976d0f7 --- /dev/null +++ b/docs/sql/restore-rpc-original.sql @@ -0,0 +1,199 @@ +-- Migration: add source_orders items to get_delivery_invitation_by_token +-- This replaces ONLY the orderItems building section for the group path. +-- Apply AFTER the base function is restored. + +-- Step 1: First restore the original function (run restore-rpc-original.sql if needed) +-- Step 2: Then run this migration + +CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, extensions +AS $$ +DECLARE + v_invitation public.delivery_invitations%rowtype; + v_group public.order_groups%rowtype; + v_order record; + v_token_hash text; + v_state text; + v_order_number text; + v_customer_name text; + v_customer_phone text; + v_order_items jsonb; + v_order_numbers jsonb; + v_now timestamptz := timezone('utc', now()); +BEGIN + IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN + RAISE EXCEPTION 'token is required'; + END IF; + + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); + + SELECT * + INTO v_invitation + FROM public.delivery_invitations + WHERE token_hash = v_token_hash; + + IF NOT found THEN + RAISE EXCEPTION 'Invitation not found'; + END IF; + + IF v_invitation.revoked_at IS NOT NULL THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; + + IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN + RAISE EXCEPTION 'Invitation expired'; + END IF; + + IF v_invitation.order_group_id IS NOT NULL THEN + SELECT * + INTO v_group + FROM public.order_groups + WHERE id = v_invitation.order_group_id; + + IF NOT found THEN + RAISE EXCEPTION 'Order group not found'; + END IF; + + v_state := CASE + WHEN v_group.delivery_status = 'agreed' THEN 'agreed' + WHEN v_group.delivery_status = 'delivered' THEN 'delivered' + WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state + ELSE 'default' + END; + + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, + last_accessed_at = v_now + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; + + v_order_number := COALESCE( + NULLIF(v_invitation.order_number, ''), + to_jsonb(v_group.order_numbers) ->> 0, + NULLIF(v_group.group_key, '') + ); + v_customer_name := COALESCE( + NULLIF(v_group.customer_name, ''), + NULLIF(v_invitation.customer_name, '') + ); + v_customer_phone := COALESCE( + NULLIF(v_group.customer_phone, ''), + NULLIF(v_group.customer_phone_normalized, ''), + NULLIF(v_invitation.customer_phone, '') + ); + + -- Build orderItems: use source_orders for real product lines if available, + -- otherwise fall back to invoice numbers from order_numbers. + v_order_items := CASE + WHEN v_group.source_orders IS NOT NULL + AND jsonb_typeof(v_group.source_orders) = 'array' + AND jsonb_array_length(v_group.source_orders) > 0 + THEN COALESCE( + (SELECT jsonb_agg( + jsonb_build_object( + 'name', COALESCE(src ->> 'nom', src ->> 'name', ''), + 'quantity', '', + 'items', COALESCE(src -> 'orderList', src -> 'items', '[]'::jsonb) + ) + ) FROM jsonb_array_elements(v_group.source_orders) AS src), + '[]'::jsonb + ) + ELSE COALESCE( + (SELECT jsonb_agg(jsonb_build_object('name', order_number, 'quantity', '')) + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(to_jsonb(v_group.order_numbers)) = 'array' THEN to_jsonb(v_group.order_numbers) + ELSE '[]'::jsonb + END + ) AS order_number), + '[]'::jsonb + ) + END; + + RETURN jsonb_build_object( + 'ok', TRUE, + 'invitation', jsonb_build_object( + 'orderId', COALESCE(v_invitation.order_group_id, v_group.id)::text, + 'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', v_order_number, + 'customerName', v_customer_name, + 'customerPhone', v_customer_phone, + 'orderItems', v_order_items, + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', NULL, + 'deliveryAgreementStatus', NULL + ) + ); + END IF; + + SELECT id, order_number, status, delivery_agreement_status, customer + INTO v_order + FROM public.orders + WHERE id = v_invitation.order_id; + + IF NOT found THEN + RAISE EXCEPTION 'Order not found'; + END IF; + + v_state := CASE v_order.status + WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice' + WHEN 'Ожидает согласования доставки' THEN 'opened' + WHEN 'Напоминание отправлено' THEN 'reminder_sent' + WHEN 'Переход отправлен' THEN 'reminder_sent' + WHEN 'Передан логисту' THEN 'transferred_to_logistics' + WHEN 'Платное хранение' THEN 'paid_storage' + WHEN 'Доставлен' THEN 'delivered' + WHEN 'Доставка согласована' THEN 'agreed' + ELSE 'default' + END; + + UPDATE public.delivery_invitations + SET + opened_at = CASE + WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now + ELSE opened_at + END, + access_count = COALESCE(access_count, 0) + 1, + last_accessed_at = v_now + WHERE id = v_invitation.id + RETURNING * INTO v_invitation; + + v_order_items := CASE + WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items' + ELSE '[]'::jsonb + END; + + RETURN jsonb_build_object( + 'ok', TRUE, + 'invitation', jsonb_build_object( + 'orderId', v_invitation.order_id::text, + 'state', v_state, + 'token', p_token, + 'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')), + 'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')), + 'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')), + 'orderItems', v_order_items, + 'availableSlots', COALESCE(to_jsonb(v_invitation.available_slots), '[]'::jsonb), + 'deliveryDate', v_invitation.delivery_date, + 'deliveryTime', v_invitation.delivery_time, + 'orderStatus', v_order.status, + 'deliveryAgreementStatus', v_order.delivery_agreement_status + ) + ); +END; +$$; + +REVOKE ALL ON FUNCTION public.get_delivery_invitation_by_token(text) FROM public; +GRANT EXECUTE ON FUNCTION public.get_delivery_invitation_by_token(text) TO anon, authenticated; diff --git a/src/components/client/DeliveryChoiceFlow.test.jsx b/src/components/client/DeliveryChoiceFlow.test.jsx index a29defd..0e29011 100644 --- a/src/components/client/DeliveryChoiceFlow.test.jsx +++ b/src/components/client/DeliveryChoiceFlow.test.jsx @@ -90,7 +90,7 @@ describe("DeliveryChoiceFlow", () => { }); describe("OrderCompositionPanel", () => { - it("renders order items with quantities when they are provided", () => { + it("renders with position count and collapsed state", () => { const markup = renderToStaticMarkup( { ); expect(markup).toContain("Состав заказа"); - expect(markup).toContain("Кухонный гарнитур"); - expect(markup).toContain("1 комплект"); - expect(markup).toContain("Фурнитура Blum"); - expect(markup).toContain("12 шт"); - expect(markup).toContain("Монтажный комплект"); + expect(markup).toContain("3 поз."); }); - it("renders reference and customer info when there are no order items", () => { + it("renders nothing when there are no order items", () => { const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Состав заказа"); - expect(markup).toContain("CD-240031"); - expect(markup).toContain("Мария Волкова"); - }); - - it("renders nothing when there are no order items and no reference info", () => { - const markup = renderToStaticMarkup( - , + , ); expect(markup).toBe(""); }); - - it("handles string items with pipe separator", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).toContain("Плинтус"); - expect(markup).toContain("5 шт"); - }); }); diff --git a/src/components/client/OrderCompositionPanel.jsx b/src/components/client/OrderCompositionPanel.jsx index 75d3f5c..a25c723 100644 --- a/src/components/client/OrderCompositionPanel.jsx +++ b/src/components/client/OrderCompositionPanel.jsx @@ -3,109 +3,98 @@ import { Badge } from "../UI/Badge"; import { Panel } from "../UI/Panel"; import { getInvitationReferenceLabel } from "./invitationReference"; -const splitOrderItem = (item) => { - if (!item) return null; +const flattenOrderProducts = (rawItems) => { + const products = []; - if (typeof item === "string") { - const [name, quantity] = item.split("|").map((p) => p.trim()); - return { name: name || item.trim(), quantity: quantity || "", items: [] }; + for (const item of rawItems) { + if (!item || typeof item !== "object") continue; + + const subOrders = Array.isArray(item.items) ? item.items : []; + + if (subOrders.length === 0) { + const name = String(item.product_name || item.name || item.nom || "").trim(); + const qty = String(item.product_quantity || item.quantity || item.count || item.amount || "").trim(); + const unit = String(item.product_ed || item.unit || "").trim(); + if (name) products.push({ name, quantity: qty, unit }); + continue; + } + + const hasSubOrders = subOrders.some((s) => typeof s === "object" && ("nom" in s || ("items" in s && Array.isArray(s.items)))); + + if (hasSubOrders) { + for (const sub of subOrders) { + if (!sub || typeof sub !== "object") continue; + const productsList = Array.isArray(sub.items) ? sub.items : []; + for (const p of productsList) { + if (!p || typeof p !== "object") continue; + const pName = String(p.product_name || p.name || "").trim(); + const pQty = String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(); + const pUnit = String(p.product_ed || p.unit || "").trim(); + if (pName) products.push({ name: pName, quantity: pQty, unit: pUnit }); + } + } + } else { + for (const p of subOrders) { + if (!p || typeof p !== "object") continue; + const pName = String(p.product_name || p.name || "").trim(); + const pQty = String(p.product_quantity || p.quantity || p.count || p.amount || "").trim(); + const pUnit = String(p.product_ed || p.unit || "").trim(); + if (pName) products.push({ name: pName, quantity: pQty, unit: pUnit }); + } + } } - if (typeof item === "object") { - const name = typeof item.name === "string" - ? item.name.trim() - : typeof item.label === "string" - ? item.label.trim() - : ""; - const quantity = typeof item.quantity === "string" - ? item.quantity.trim() - : typeof item.quantity === "number" - ? String(item.quantity) - : ""; - const nestedItems = Array.isArray(item.items) - ? item.items.map((sub) => { - if (!sub || typeof sub !== "object") return null; - return { - name: String(sub.product_name || sub.name || sub.title || "").trim(), - quantity: String(sub.product_quantity || sub.quantity || sub.count || sub.amount || "").trim(), - }; - }).filter(Boolean) - : []; - return { name: name || "Позиция", quantity, items: nestedItems }; - } - - return null; -}; - -const buildDetailRows = (invitation) => { - const rows = []; - const reference = getInvitationReferenceLabel(invitation); - if (reference && reference !== "Счет —") { - rows.push({ label: "Счет", value: reference.replace(/^Счета?:\s*/, "") }); - } - if (invitation.customerName) { - rows.push({ label: "Клиент", value: invitation.customerName }); - } - return rows; + return products; }; export const OrderCompositionPanel = ({ invitation = {} }) => { - const orderItems = (invitation.orderItems || invitation.items || []) - .map(splitOrderItem) - .filter(Boolean); - - const detailRows = buildDetailRows(invitation); - const hasContent = orderItems.length > 0 || detailRows.length > 0; - - if (!hasContent) return null; - + const rawItems = invitation.orderItems || invitation.items || []; + const products = flattenOrderProducts(rawItems); const reference = getInvitationReferenceLabel(invitation); + const [isExpanded, setIsExpanded] = React.useState(false); + + if (products.length === 0) return null; + return ( -

- Состав заказа {reference !== "Счет —" ? reference : ""} -

- - {detailRows.length > 0 && !orderItems.length ? ( + + {isExpanded && (
- {detailRows.map((row) => ( + {products.map((product, idx) => (
- {row.label} - {row.value} -
- ))} -
- ) : null} - - {orderItems.length > 0 ? ( -
- {orderItems.map((item) => ( -
-
- {item.name} - {item.quantity ? {item.quantity} : null} -
- {item.items && item.items.length > 0 ? ( -
- {item.items.map((sub, idx) => ( -
- {sub.name} - {sub.quantity ? {sub.quantity} : null} -
- ))} -
+ {product.name} + {(product.quantity || product.unit) ? ( + {[product.quantity, product.unit].filter(Boolean).join(" ")} ) : null}
))}
- ) : null} + )}
); }; diff --git a/src/services/deliveryInvitationApi.js b/src/services/deliveryInvitationApi.js index 501484f..d8077c7 100644 --- a/src/services/deliveryInvitationApi.js +++ b/src/services/deliveryInvitationApi.js @@ -183,60 +183,6 @@ export const __resetLocalDeliveryInvitationCache = () => { localDeliveryInvitationCache.clear(); }; -const enrichOrderItemsFromEdgeFunction = (invitation, token) => { - // Fire-and-forget: try the Edge Function in the background to get richer - // order item data (with source_orders). On success, update the cache. - // The initial RPC response is returned immediately so the page renders - // without delay; the richer data arrives shortly after. - if (!token || isLocalClientInvitationToken(token)) { - return invitation; - } - - const hasOnlyInvoiceItems = Array.isArray(invitation.orderItems) - && invitation.orderItems.every( - (item) => typeof item === "object" && item !== null - && (!Array.isArray(item.items) || item.items.length === 0) - && (!item.quantity || item.quantity === ""), - ); - - if (!hasOnlyInvoiceItems) { - return invitation; - } - - invokeDeliveryFunction("get-delivery-invitation", { token }) - .then((response) => { - if (response?.invitation?.orderItems) { - const enriched = { - ...invitation, - orderItems: response.invitation.orderItems, - }; - cacheInvitation(enriched); - const storage = getLocalStorage(); - if (storage) { - try { - storage.setItem( - getLocalStorageKey(token), - JSON.stringify(enriched), - ); - window.dispatchEvent( - new StorageEvent("storage", { - key: getLocalStorageKey(token), - newValue: JSON.stringify(enriched), - }), - ); - } catch { - // Ignore storage errors. - } - } - } - }) - .catch(() => { - // Silently ignore — the initial RPC data is already shown. - }); - - return invitation; -}; - export const fetchDeliveryInvitation = async (token) => { if (!token) { throw new Error("Token is required"); @@ -262,8 +208,7 @@ export const fetchDeliveryInvitation = async (token) => { p_token: token, }); if (response?.invitation) { - const enriched = enrichOrderItemsFromEdgeFunction(response.invitation, token); - return cacheInvitation(enriched); + return cacheInvitation(response.invitation); } if (isLocalClientInvitationToken(token)) { return cacheInvitation(buildFallbackInvitation(token));