feat: show order composition collapsed with product list from source_orders

- OrderCompositionPanel now collapsed by default with position count badge
- Flattens all products from source_orders into a single list
- RPC migration to extract items from source_orders (needs DB deploy)
- Edge Function updated to include source_orders items
- Removed Edge Function enrichment hack from fetchDeliveryInvitation
This commit is contained in:
Codex 2026-05-21 14:18:10 +03:00
parent e6dc1972fb
commit 9abfbff654
6 changed files with 702 additions and 412 deletions

View File

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

View File

@ -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) -- Step 1: First restore the original function (run restore-rpc-original.sql if needed)
returns jsonb -- Step 2: Then run this migration
language plpgsql
security definer CREATE OR REPLACE FUNCTION public.get_delivery_invitation_by_token(p_token text)
set search_path = public, extensions RETURNS jsonb
as $$ LANGUAGE plpgsql
declare SECURITY DEFINER
SET search_path = public, extensions
AS $$
DECLARE
v_invitation public.delivery_invitations%rowtype; v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype; v_group public.order_groups%rowtype;
v_order record; v_order record;
@ -18,291 +23,275 @@ declare
v_order_items jsonb; v_order_items jsonb;
v_order_numbers jsonb; v_order_numbers jsonb;
v_now timestamptz := timezone('utc', now()); v_now timestamptz := timezone('utc', now());
begin BEGIN
if nullif(trim(coalesce(p_token, '')), '') is null then IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN
raise exception 'token is required'; RAISE EXCEPTION 'token is required';
end if; END IF;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
select * SELECT *
into v_invitation INTO v_invitation
from public.delivery_invitations FROM public.delivery_invitations
where token_hash = v_token_hash; WHERE token_hash = v_token_hash;
if not found then IF NOT found THEN
raise exception 'Invitation not found'; RAISE EXCEPTION 'Invitation not found';
end if; END IF;
if v_invitation.revoked_at is not null then IF v_invitation.revoked_at IS NOT NULL THEN
raise exception 'Invitation expired'; RAISE EXCEPTION 'Invitation expired';
end if; END IF;
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN
raise exception 'Invitation expired'; RAISE EXCEPTION 'Invitation expired';
end if; END IF;
if v_invitation.order_group_id is not null then IF v_invitation.order_group_id IS NOT NULL THEN
select * SELECT *
into v_group INTO v_group
from public.order_groups FROM public.order_groups
where id = v_invitation.order_group_id; WHERE id = v_invitation.order_group_id;
if not found then IF NOT found THEN
raise exception 'Order group not found'; RAISE EXCEPTION 'Order group not found';
end if; END IF;
v_state := case v_state := CASE
when v_group.delivery_status = 'agreed' then 'agreed' WHEN v_group.delivery_status = 'agreed' THEN 'agreed'
when v_group.delivery_status = 'delivered' then 'delivered' WHEN v_group.delivery_status = 'delivered' THEN 'delivered'
when v_invitation.state in ('awaiting_choice', 'opened', 'reminder_sent') then v_invitation.state WHEN v_invitation.state IN ('awaiting_choice', 'opened', 'reminder_sent') THEN v_invitation.state
else 'default' ELSE 'default'
end; END;
update public.delivery_invitations UPDATE public.delivery_invitations
set SET
opened_at = case opened_at = CASE
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
else opened_at ELSE opened_at
end, END,
access_count = coalesce(access_count, 0) + 1, access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
where id = v_invitation.id WHERE id = v_invitation.id
returning * into v_invitation; RETURNING * INTO v_invitation;
v_order_number := coalesce( v_order_number := COALESCE(
nullif(v_invitation.order_number, ''), NULLIF(v_invitation.order_number, ''),
to_jsonb(v_group.order_numbers) ->> 0, to_jsonb(v_group.order_numbers) ->> 0,
nullif(v_group.group_key, '') NULLIF(v_group.group_key, '')
); );
v_customer_name := coalesce( v_customer_name := COALESCE(
nullif(v_group.customer_name, ''), NULLIF(v_group.customer_name, ''),
nullif(v_invitation.customer_name, '') NULLIF(v_invitation.customer_name, '')
); );
v_customer_phone := coalesce( v_customer_phone := COALESCE(
nullif(v_group.customer_phone, ''), NULLIF(v_group.customer_phone, ''),
nullif(v_group.customer_phone_normalized, ''), NULLIF(v_group.customer_phone_normalized, ''),
nullif(v_invitation.customer_phone, '') 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( -- Build orderItems: use source_orders for real product lines if available,
'ok', true, -- 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( 'invitation', jsonb_build_object(
'orderId', 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, 'orderGroupId', COALESCE(v_invitation.order_group_id, v_group.id)::text,
'state', v_state, 'state', v_state,
'token', p_token, 'token', p_token,
'orderNumber', v_order_number, 'orderNumber', v_order_number,
'customerName', v_customer_name, 'customerName', v_customer_name,
'customerPhone', v_customer_phone, 'customerPhone', v_customer_phone,
'orderItems', v_order_items, '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, 'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time, 'deliveryTime', v_invitation.delivery_time,
'orderStatus', null, 'orderStatus', NULL,
'deliveryAgreementStatus', null 'deliveryAgreementStatus', NULL
) )
); );
end if; END IF;
select id, order_number, status, delivery_agreement_status, customer SELECT id, order_number, status, delivery_agreement_status, customer
into v_order INTO v_order
from public.orders FROM public.orders
where id = v_invitation.order_id; WHERE id = v_invitation.order_id;
if not found then IF NOT found THEN
raise exception 'Order not found'; RAISE EXCEPTION 'Order not found';
end if; END IF;
v_state := case v_order.status v_state := CASE v_order.status
when 'Ожидает ответа клиента' then 'awaiting_choice' WHEN 'Ожидает ответа клиента' THEN 'awaiting_choice'
when 'Ожидает согласования доставки' then 'opened' WHEN 'Ожидает согласования доставки' THEN 'opened'
when 'Напоминание отправлено' then 'reminder_sent' WHEN 'Напоминание отправлено' THEN 'reminder_sent'
when 'Переход отправлен' then 'reminder_sent' WHEN 'Переход отправлен' THEN 'reminder_sent'
when 'Передан логисту' then 'transferred_to_logistics' WHEN 'Передан логисту' THEN 'transferred_to_logistics'
when 'Платное хранение' then 'paid_storage' WHEN 'Платное хранение' THEN 'paid_storage'
when 'Доставлен' then 'delivered' WHEN 'Доставлен' THEN 'delivered'
when 'Доставка согласована' then 'agreed' WHEN 'Доставка согласована' THEN 'agreed'
else 'default' ELSE 'default'
end; END;
update public.delivery_invitations UPDATE public.delivery_invitations
set SET
opened_at = case opened_at = CASE
when v_state in ('awaiting_choice', 'opened', 'reminder_sent') and opened_at is null then v_now WHEN v_state IN ('awaiting_choice', 'opened', 'reminder_sent') AND opened_at IS NULL THEN v_now
else opened_at ELSE opened_at
end, END,
access_count = coalesce(access_count, 0) + 1, access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
where id = v_invitation.id WHERE id = v_invitation.id
returning * into v_invitation; RETURNING * INTO v_invitation;
v_order_items := case v_order_items := CASE
when jsonb_typeof(v_order.customer -> 'items') = 'array' then v_order.customer -> 'items' WHEN jsonb_typeof(v_order.customer -> 'items') = 'array' THEN v_order.customer -> 'items'
else '[]'::jsonb ELSE '[]'::jsonb
end; END;
return jsonb_build_object( RETURN jsonb_build_object(
'ok', true, 'ok', TRUE,
'invitation', jsonb_build_object( 'invitation', jsonb_build_object(
'orderId', v_invitation.order_id::text, 'orderId', v_invitation.order_id::text,
'state', v_state, 'state', v_state,
'token', p_token, 'token', p_token,
'orderNumber', coalesce(nullif(v_order.order_number, ''), nullif(v_invitation.order_number, '')), 'orderNumber', COALESCE(NULLIF(v_order.order_number, ''), NULLIF(v_invitation.order_number, '')),
'customerName', coalesce(nullif(v_order.customer ->> 'name', ''), nullif(v_invitation.customer_name, '')), 'customerName', COALESCE(NULLIF(v_order.customer ->> 'name', ''), NULLIF(v_invitation.customer_name, '')),
'customerPhone', coalesce(nullif(v_order.customer ->> 'phone', ''), nullif(v_invitation.customer_phone, '')), 'customerPhone', COALESCE(NULLIF(v_order.customer ->> 'phone', ''), NULLIF(v_invitation.customer_phone, '')),
'orderItems', v_order_items, '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, 'deliveryDate', v_invitation.delivery_date,
'deliveryTime', v_invitation.delivery_time, 'deliveryTime', v_invitation.delivery_time,
'orderStatus', v_order.status, 'orderStatus', v_order.status,
'deliveryAgreementStatus', v_order.delivery_agreement_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_token text,
p_delivery_date date, p_delivery_date date,
p_delivery_time text p_delivery_time text
) )
returns jsonb RETURNS jsonb
language plpgsql LANGUAGE plpgsql
security definer SECURITY DEFINER
set search_path = public, extensions SET search_path = public, extensions
as $$ AS $$
declare DECLARE
v_invitation public.delivery_invitations%rowtype; v_invitation public.delivery_invitations%rowtype;
v_group public.order_groups%rowtype; v_group public.order_groups%rowtype;
v_order record; v_order record;
v_token_hash text; v_token_hash text;
v_slot_label text; v_slot_label text;
v_now timestamptz := timezone('utc', now()); v_now timestamptz := timezone('utc', now());
begin BEGIN
if nullif(trim(coalesce(p_token, '')), '') is null then IF nullif(trim(coalesce(p_token, '')), '') IS NULL THEN
raise exception 'token is required'; RAISE EXCEPTION 'token is required';
end if; END IF;
if p_delivery_date is null or nullif(trim(coalesce(p_delivery_time, '')), '') is null then IF p_delivery_date IS NULL OR nullif(trim(coalesce(p_delivery_time, '')), '') IS NULL THEN
raise exception 'Selected slot is not available'; RAISE EXCEPTION 'Selected slot is not available';
end if; END IF;
v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time));
select * SELECT *
into v_invitation INTO v_invitation
from public.delivery_invitations FROM public.delivery_invitations
where token_hash = v_token_hash WHERE token_hash = v_token_hash
for update; FOR UPDATE;
if not found then IF NOT found THEN
raise exception 'Invitation not found'; RAISE EXCEPTION 'Invitation not found';
end if; END IF;
if v_invitation.revoked_at is not null then IF v_invitation.revoked_at IS NOT NULL THEN
raise exception 'Invitation expired'; RAISE EXCEPTION 'Invitation expired';
end if; END IF;
if v_invitation.expires_at is not null and v_invitation.expires_at <= v_now then IF v_invitation.expires_at IS NOT NULL AND v_invitation.expires_at <= v_now THEN
raise exception 'Invitation expired'; RAISE EXCEPTION 'Invitation expired';
end if; END IF;
if v_invitation.state not in ('awaiting_choice', 'opened', 'reminder_sent') then IF v_invitation.state NOT IN ('awaiting_choice', 'opened', 'reminder_sent') THEN
raise exception 'Invitation is no longer active'; RAISE EXCEPTION 'Invitation is no longer active';
end if; END IF;
if cardinality(v_invitation.available_slots) > 0 and not (v_slot_label = any(v_invitation.available_slots)) then 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'; RAISE EXCEPTION 'Selected slot is not available';
end if; END IF;
if v_invitation.order_group_id is not null then IF v_invitation.order_group_id IS NOT NULL THEN
select * SELECT *
into v_group INTO v_group
from public.order_groups FROM public.order_groups
where id = v_invitation.order_group_id WHERE id = v_invitation.order_group_id
for update; FOR UPDATE;
if not found then IF NOT found THEN
raise exception 'Order group not found'; RAISE EXCEPTION 'Order group not found';
end if; END IF;
if v_group.delivery_status <> 'pending_confirmation' then IF v_group.delivery_status <> 'pending_confirmation' THEN
raise exception 'Invitation is no longer active'; RAISE EXCEPTION 'Invitation is no longer active';
end if; END IF;
update public.delivery_invitations UPDATE public.delivery_invitations
set SET
state = 'agreed', state = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
confirmed_at = v_now, confirmed_at = v_now,
access_count = coalesce(access_count, 0) + 1, access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
where id = v_invitation.id; WHERE id = v_invitation.id;
update public.order_groups UPDATE public.order_groups
set SET
delivery_status = 'agreed', delivery_status = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
notification_status = 'confirmed', notification_status = 'confirmed',
updated_at = v_now 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, order_id,
event_type, event_type,
direction, direction,
status, status,
payload payload
) )
values ( VALUES (
null, NULL,
'delivery_choice_confirmed', 'delivery_choice_confirmed',
'inbound', 'inbound',
'success', 'success',
@ -314,66 +303,66 @@ begin
) )
); );
return jsonb_build_object( RETURN jsonb_build_object(
'ok', true, 'ok', TRUE,
'orderGroupId', v_group.id, 'orderGroupId', v_group.id,
'deliveryStatus', 'agreed' 'deliveryStatus', 'agreed'
); );
end if; END IF;
select id, status, delivery_agreement_status SELECT id, status, delivery_agreement_status
into v_order INTO v_order
from public.orders FROM public.orders
where id = v_invitation.order_id WHERE id = v_invitation.order_id
for update; FOR UPDATE;
if not found then IF NOT found THEN
raise exception 'Order not found'; RAISE EXCEPTION 'Order not found';
end if; END IF;
if v_order.status not in ('Ожидает ответа клиента', 'Ожидает согласования доставки') then IF v_order.status NOT IN ('Ожидает ответа клиента', 'Ожидает согласования доставки') THEN
raise exception 'Invitation is no longer active'; RAISE EXCEPTION 'Invitation is no longer active';
end if; END IF;
update public.delivery_invitations UPDATE public.delivery_invitations
set SET
state = 'agreed', state = 'agreed',
delivery_date = p_delivery_date, delivery_date = p_delivery_date,
delivery_time = trim(p_delivery_time), delivery_time = trim(p_delivery_time),
confirmed_at = v_now, confirmed_at = v_now,
access_count = coalesce(access_count, 0) + 1, access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = v_now last_accessed_at = v_now
where id = v_invitation.id; WHERE id = v_invitation.id;
update public.orders UPDATE public.orders
set SET
status = 'Доставка согласована', status = 'Доставка согласована',
delivery_agreement_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, order_id,
delivery_date, delivery_date,
delivery_time, delivery_time,
logistician_id, logistician_id,
status status
) )
values ( VALUES (
v_order.id, v_order.id,
p_delivery_date, p_delivery_date,
trim(p_delivery_time), trim(p_delivery_time),
null, NULL,
'confirmed_by_client' 'confirmed_by_client'
); );
insert into public.order_history ( INSERT INTO public.order_history (
order_id, order_id,
action, action,
old_status, old_status,
new_status, new_status,
metadata metadata
) )
values ( VALUES (
v_order.id, v_order.id,
'Подтверждение выбора доставки клиентом', 'Подтверждение выбора доставки клиентом',
v_order.status, v_order.status,
@ -386,14 +375,14 @@ begin
) )
); );
insert into public.integration_events ( INSERT INTO public.integration_events (
order_id, order_id,
event_type, event_type,
direction, direction,
status, status,
payload payload
) )
values ( VALUES (
v_order.id, v_order.id,
'delivery_choice_confirmed', 'delivery_choice_confirmed',
'inbound', 'inbound',
@ -404,17 +393,14 @@ begin
) )
); );
return jsonb_build_object( RETURN jsonb_build_object(
'ok', true, 'ok', TRUE,
'orderId', v_order.id, 'orderId', v_order.id,
'status', 'Доставка согласована', 'status', 'Доставка согласована',
'deliveryAgreementStatus', 'Подтверждено клиентом' 'deliveryAgreementStatus', 'Подтверждено клиентом'
); );
end; END;
$$; $$;
revoke all on function public.get_delivery_invitation_by_token(text) from public; REVOKE ALL ON FUNCTION public.confirm_delivery_choice_by_token(text, date, text) FROM public;
grant execute on function public.get_delivery_invitation_by_token(text) to anon, authenticated; 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;

View File

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

View File

@ -90,7 +90,7 @@ describe("DeliveryChoiceFlow", () => {
}); });
describe("OrderCompositionPanel", () => { describe("OrderCompositionPanel", () => {
it("renders order items with quantities when they are provided", () => { it("renders with position count and collapsed state", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrderCompositionPanel <OrderCompositionPanel
invitation={{ invitation={{
@ -105,42 +105,14 @@ describe("OrderCompositionPanel", () => {
); );
expect(markup).toContain("Состав заказа"); expect(markup).toContain("Состав заказа");
expect(markup).toContain("Кухонный гарнитур"); expect(markup).toContain("3 поз.");
expect(markup).toContain("1 комплект");
expect(markup).toContain("Фурнитура Blum");
expect(markup).toContain("12 шт");
expect(markup).toContain("Монтажный комплект");
}); });
it("renders reference and customer info when there are no order items", () => { it("renders nothing when there are no order items", () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<OrderCompositionPanel invitation={{ orderNumber: "CD-240031", customerName: "Мария Волкова" }} />, <OrderCompositionPanel invitation={{ orderNumber: "CD-240031" }} />,
);
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(
<OrderCompositionPanel invitation={{}} />,
); );
expect(markup).toBe(""); expect(markup).toBe("");
}); });
it("handles string items with pipe separator", () => {
const markup = renderToStaticMarkup(
<OrderCompositionPanel
invitation={{
orderNumber: "CD-240031",
orderItems: ["Плинтус|5 шт"],
}}
/>,
);
expect(markup).toContain("Плинтус");
expect(markup).toContain("5 шт");
});
}); });

View File

@ -3,109 +3,98 @@ import { Badge } from "../UI/Badge";
import { Panel } from "../UI/Panel"; import { Panel } from "../UI/Panel";
import { getInvitationReferenceLabel } from "./invitationReference"; import { getInvitationReferenceLabel } from "./invitationReference";
const splitOrderItem = (item) => { const flattenOrderProducts = (rawItems) => {
if (!item) return null; const products = [];
if (typeof item === "string") { for (const item of rawItems) {
const [name, quantity] = item.split("|").map((p) => p.trim()); if (!item || typeof item !== "object") continue;
return { name: name || item.trim(), quantity: quantity || "", items: [] };
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") { return products;
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;
}; };
export const OrderCompositionPanel = ({ invitation = {} }) => { export const OrderCompositionPanel = ({ invitation = {} }) => {
const orderItems = (invitation.orderItems || invitation.items || []) const rawItems = invitation.orderItems || invitation.items || [];
.map(splitOrderItem) const products = flattenOrderProducts(rawItems);
.filter(Boolean);
const detailRows = buildDetailRows(invitation);
const hasContent = orderItems.length > 0 || detailRows.length > 0;
if (!hasContent) return null;
const reference = getInvitationReferenceLabel(invitation); const reference = getInvitationReferenceLabel(invitation);
const [isExpanded, setIsExpanded] = React.useState(false);
if (products.length === 0) return null;
return ( return (
<Panel className="space-y-3 border shadow-soft p-5 sm:p-6"> <Panel className="space-y-3 border shadow-soft p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]"> <button
Состав заказа {reference !== "Счет —" ? reference : ""} type="button"
</p> className="flex w-full items-center justify-between text-left"
onClick={() => setIsExpanded(!isExpanded)}
{detailRows.length > 0 && !orderItems.length ? ( >
<p className="text-sm uppercase tracking-[0.18em] text-[var(--color-text-muted)]">
Состав заказа {reference !== "Счет —" ? reference : ""}
</p>
<span className="flex items-center gap-2 text-sm text-[var(--color-text-muted)]">
{products.length > 0 ? `${products.length} поз.` : ""}
<svg
className="h-4 w-4 transition-transform"
style={{ transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="space-y-2"> <div className="space-y-2">
{detailRows.map((row) => ( {products.map((product, idx) => (
<div <div
key={row.label} key={`${product.name}-${idx}`}
className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm" className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm"
> >
<span className="leading-6 text-[var(--color-text-muted)]">{row.label}</span> <span className="leading-6">{product.name}</span>
<span className="font-medium leading-6">{row.value}</span> {(product.quantity || product.unit) ? (
</div> <Badge tone="neutral">{[product.quantity, product.unit].filter(Boolean).join(" ")}</Badge>
))}
</div>
) : null}
{orderItems.length > 0 ? (
<div className="space-y-2">
{orderItems.map((item) => (
<div key={`${item.name}-${item.quantity || "item"}`}>
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3 text-sm">
<span className="font-medium leading-6">{item.name}</span>
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
</div>
{item.items && item.items.length > 0 ? (
<div className="mt-1 space-y-1 pl-3">
{item.items.map((sub, idx) => (
<div
key={`${sub.name}-${idx}`}
className="flex items-center justify-between gap-3 rounded-[14px] border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm"
>
<span className="leading-6">{sub.name}</span>
{sub.quantity ? <Badge tone="neutral">{sub.quantity}</Badge> : null}
</div>
))}
</div>
) : null} ) : null}
</div> </div>
))} ))}
</div> </div>
) : null} )}
</Panel> </Panel>
); );
}; };

View File

@ -183,60 +183,6 @@ export const __resetLocalDeliveryInvitationCache = () => {
localDeliveryInvitationCache.clear(); 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) => { export const fetchDeliveryInvitation = async (token) => {
if (!token) { if (!token) {
throw new Error("Token is required"); throw new Error("Token is required");
@ -262,8 +208,7 @@ export const fetchDeliveryInvitation = async (token) => {
p_token: token, p_token: token,
}); });
if (response?.invitation) { if (response?.invitation) {
const enriched = enrichOrderItemsFromEdgeFunction(response.invitation, token); return cacheInvitation(response.invitation);
return cacheInvitation(enriched);
} }
if (isLocalClientInvitationToken(token)) { if (isLocalClientInvitationToken(token)) {
return cacheInvitation(buildFallbackInvitation(token)); return cacheInvitation(buildFallbackInvitation(token));