diff --git a/docs/sql/public-delivery-choice-rpc.sql b/docs/sql/public-delivery-choice-rpc.sql index c88e936..5b1fc94 100644 --- a/docs/sql/public-delivery-choice-rpc.sql +++ b/docs/sql/public-delivery-choice-rpc.sql @@ -220,6 +220,11 @@ BEGIN RAISE EXCEPTION 'Selected slot is not available'; END IF; + -- Business rule: never allow Sunday delivery (0 = Sunday in extract(dow)) + IF extract(dow from p_delivery_date) = 0 THEN + RAISE EXCEPTION 'Delivery is not available on Sunday'; + END IF; + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index a8cc529..300f0a1 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -42,9 +42,25 @@ const addDaysToDateKey = (dateKey, amount) => { return baseDate.toISOString().slice(0, 10); }; +const isSundayKey = (dateKey) => { + if (!dateKey) return true; + const d = new Date(`${dateKey}T12:00:00Z`); + return d.getUTCDay() === 0; +}; + +const getNextWorkdayKey = (dateKey) => { + let next = addDaysToDateKey(dateKey, 1); + while (isSundayKey(next)) { + next = addDaysToDateKey(next, 1); + } + return next; +}; + const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => { const todayKey = getBusinessTodayKey(referenceDate); - return new Set([addDaysToDateKey(todayKey, 1), addDaysToDateKey(todayKey, 2)].filter(Boolean)); + const firstWorkday = getNextWorkdayKey(todayKey); + const secondWorkday = getNextWorkdayKey(firstWorkday); + return new Set([firstWorkday, secondWorkday].filter(Boolean)); }; const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => { @@ -65,15 +81,15 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date()) const deliveryTime = invitation.deliveryTime; if (!rawSlots.length && !deliveryDate) { - // Fallback: generate default delivery slots (tomorrow + dayAfter, both halves) + // Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday) const todayKey = getBusinessTodayKey(referenceDate); - const tomorrow = addDaysToDateKey(todayKey, 1); - const dayAfter = addDaysToDateKey(todayKey, 2); + const firstWorkday = getNextWorkdayKey(todayKey); + const secondWorkday = getNextWorkdayKey(firstWorkday); return [ - { id: `slot-${tomorrow}-first`, date: tomorrow, time: "Первая половина дня" }, - { id: `slot-${tomorrow}-second`, date: tomorrow, time: "Вторая половина дня" }, - { id: `slot-${dayAfter}-first`, date: dayAfter, time: "Первая половина дня" }, - { id: `slot-${dayAfter}-second`, date: dayAfter, time: "Вторая половина дня" }, + { id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" }, + { id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" }, + { id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" }, + { id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" }, ].filter((s) => s.date); } diff --git a/src/pages/ClientDeliveryPage.test.js b/src/pages/ClientDeliveryPage.test.js index e9b1d3e..0f5b2d2 100644 --- a/src/pages/ClientDeliveryPage.test.js +++ b/src/pages/ClientDeliveryPage.test.js @@ -187,4 +187,28 @@ describe("ClientDeliveryPage helpers", () => { "По этому заказу согласование доставки завершено или передано логисту.", ); }); + + it("skips Sunday when generating fallback delivery slots from Friday", () => { + // 2026-04-17 is Friday → next workday = Saturday 18, then Monday 20 (skip Sunday 19) + const slots = groupSlotsFromInvitation( + { availableSlots: [], deliveryDate: null }, + new Date("2026-04-17T09:00:00Z"), + ); + const dates = [...new Set(slots.map((s) => s.date))]; + expect(dates).not.toContain("2026-04-19"); // Sunday + expect(dates).toContain("2026-04-18"); // Saturday + expect(dates).toContain("2026-04-20"); // Monday + }); + + it("skips Sunday when generating fallback delivery slots from Saturday", () => { + // 2026-04-18 is Saturday → next workday = Monday 20, then Tuesday 21 + const slots = groupSlotsFromInvitation( + { availableSlots: [], deliveryDate: null }, + new Date("2026-04-18T09:00:00Z"), + ); + const dates = [...new Set(slots.map((s) => s.date))]; + expect(dates).not.toContain("2026-04-19"); // Sunday + expect(dates).toContain("2026-04-20"); // Monday + expect(dates).toContain("2026-04-21"); // Tuesday + }); }); diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index 5d7450e..a7fc938 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -137,8 +137,22 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => { return next; }; - const firstDay = formatCrimeaDate(addDays(now, 1)); - const secondDay = formatCrimeaDate(addDays(now, 2)); + // Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery + const isSunday = (date: Date) => date.getUTCDay() === 0; + + const getNextWorkday = (date: Date) => { + let next = addDays(date, 1); + while (isSunday(next)) { + next = addDays(next, 1); + } + return next; + }; + + const firstWorkday = getNextWorkday(now); + const secondWorkday = getNextWorkday(firstWorkday); + + const firstDay = formatCrimeaDate(firstWorkday); + const secondDay = formatCrimeaDate(secondWorkday); return [ `${firstDay}, Первая половина дня`, diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index d83eadd..33b29eb 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -31,6 +31,13 @@ type ConfirmBody = { const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); +const isWeekendDate = (value: string) => { + if (!isValidDate(value)) return false; + const date = new Date(`${value}T12:00:00Z`); + const weekday = date.getUTCDay(); + return weekday === 0; // 0=Sunday — never allow Sunday delivery +}; + const resolveRequestedSlot = ( invitation: { delivery_date?: string | null; @@ -52,6 +59,11 @@ const resolveRequestedSlot = ( return { deliveryDate, deliveryTime, deliveryType }; } + // Reject Sunday for delivery (business rule: never deliver on Sunday) + if (isWeekendDate(deliveryDate)) { + return null; + } + const slotLabel = `${deliveryDate}, ${deliveryTime}`; const availableSlots = invitation.available_slots || []; diff --git a/supabase/schema.sql b/supabase/schema.sql index 9424bf1..484f0ac 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -758,6 +758,11 @@ begin raise exception 'Selected slot is not available'; end if; + -- Business rule: never allow Sunday delivery (0 = Sunday in extract(dow)) + if extract(dow from p_delivery_date) = 0 then + raise exception 'Delivery is not available on Sunday'; + end if; + v_token_hash := encode(digest(p_token, 'sha256'), 'hex'); v_slot_label := concat(p_delivery_date::text, ', ', trim(p_delivery_time)); diff --git a/volumes/functions/confirm-delivery-choice/index.ts b/volumes/functions/confirm-delivery-choice/index.ts index f105c99..33b29eb 100644 --- a/volumes/functions/confirm-delivery-choice/index.ts +++ b/volumes/functions/confirm-delivery-choice/index.ts @@ -24,10 +24,20 @@ type ConfirmBody = { token?: string; deliveryDate?: string; deliveryTime?: string; + deliveryType?: string; + pickupDate?: string; + pickupTimeSlot?: string; }; const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); +const isWeekendDate = (value: string) => { + if (!isValidDate(value)) return false; + const date = new Date(`${value}T12:00:00Z`); + const weekday = date.getUTCDay(); + return weekday === 0; // 0=Sunday — never allow Sunday delivery +}; + const resolveRequestedSlot = ( invitation: { delivery_date?: string | null; @@ -36,6 +46,7 @@ const resolveRequestedSlot = ( }, body: ConfirmBody, ) => { + const deliveryType = body.deliveryType || "delivery"; const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim(); const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim(); @@ -43,6 +54,16 @@ const resolveRequestedSlot = ( return null; } + // For pickup, we allow slots outside the invitation's available_slots + if (deliveryType === "pickup") { + return { deliveryDate, deliveryTime, deliveryType }; + } + + // Reject Sunday for delivery (business rule: never deliver on Sunday) + if (isWeekendDate(deliveryDate)) { + return null; + } + const slotLabel = `${deliveryDate}, ${deliveryTime}`; const availableSlots = invitation.available_slots || []; @@ -50,7 +71,7 @@ const resolveRequestedSlot = ( return null; } - return { deliveryDate, deliveryTime }; + return { deliveryDate, deliveryTime, deliveryType }; }; Deno.serve(async (request) => { @@ -127,10 +148,12 @@ Deno.serve(async (request) => { return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders); } + const deliveryType = body.deliveryType || "delivery"; + if (invitation.order_group_id) { const { data: currentGroup, error: groupError } = await supabase .from("order_groups") - .select("id, delivery_status") + .select("id, delivery_status, delivery_address, customer_address") .eq("id", invitation.order_group_id) .single(); @@ -138,6 +161,14 @@ Deno.serve(async (request) => { throw groupError; } + // When user switches from pickup to delivery but has no address → requires_address + const hasAddress = invitation.delivery_address?.trim() || currentGroup?.delivery_address?.trim() || currentGroup?.customer_address?.trim(); + const effectiveDeliveryStatus = deliveryType === "pickup" + ? "pickup" + : hasAddress + ? "agreed" + : "requires_address"; + if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") { return jsonResponse( { @@ -177,15 +208,25 @@ Deno.serve(async (request) => { throw invitationUpdateError; } + const groupUpdateData: Record = { + delivery_status: effectiveDeliveryStatus, + delivery_date: requestedSlot.deliveryDate, + delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + notification_status: effectiveDeliveryStatus === "requires_address" ? "address_required" : "confirmed", + updated_at: new Date().toISOString(), + }; + + if (deliveryType === "pickup") { + groupUpdateData.pickup_date = body.pickupDate || requestedSlot.deliveryDate || null; + groupUpdateData.pickup_time_slot = body.pickupTimeSlot || requestedSlot.deliveryTime || null; + // Pickup orders don't need a driver — clear assignment + groupUpdateData.assigned_driver_id = null; + } + const { error: groupUpdateError } = await supabase .from("order_groups") - .update({ - delivery_status: "agreed", - delivery_date: requestedSlot.deliveryDate, - delivery_time: requestedSlot.deliveryTime, - notification_status: "confirmed", - updated_at: new Date().toISOString(), - }) + .update(groupUpdateData) .eq("id", invitation.order_group_id); if (groupUpdateError) { @@ -197,10 +238,13 @@ Deno.serve(async (request) => { order_group_id: invitation.order_group_id, action: "client_confirmed", old_value: currentGroup.delivery_status, - new_value: "agreed", + new_value: effectiveDeliveryStatus, details: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, source: "auto", }, }); @@ -215,6 +259,9 @@ Deno.serve(async (request) => { delivery_invitation_id: invitation.id, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, }); @@ -222,7 +269,8 @@ Deno.serve(async (request) => { { ok: true, orderGroupId: invitation.order_group_id, - deliveryStatus: "agreed", + deliveryStatus: effectiveDeliveryStatus, + pickupCode: groupUpdateData.pickup_code || null, }, 200, corsHeaders, @@ -314,6 +362,9 @@ Deno.serve(async (request) => { new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus, delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, }); @@ -329,6 +380,9 @@ Deno.serve(async (request) => { payload: { delivery_date: requestedSlot.deliveryDate, delivery_time: requestedSlot.deliveryTime, + delivery_type: deliveryType, + pickup_date: body.pickupDate || null, + pickup_time_slot: body.pickupTimeSlot || null, }, });