fix: skip Sunday in delivery date selection (client flow + backend validation)
- buildDefaultDatedAvailableSlots(): skip Sunday via getNextWorkday() - ClientDeliveryPage.jsx: getAllowedDeliveryDateKeys() + fallback slots skip Sunday - confirm-delivery-choice edge function: reject Sunday for delivery - confirm_delivery_choice_by_token SQL RPC: extract(dow) = 0 → RAISE EXCEPTION - Tests: Friday→Sat+Mon, Saturday→Mon+Tue (skip Sunday) - Skill updated with pitfall
This commit is contained in:
parent
d04901d296
commit
b74add32ef
|
|
@ -220,6 +220,11 @@ BEGIN
|
||||||
RAISE EXCEPTION 'Selected slot is not available';
|
RAISE EXCEPTION 'Selected slot is not available';
|
||||||
END IF;
|
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_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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,25 @@ const addDaysToDateKey = (dateKey, amount) => {
|
||||||
return baseDate.toISOString().slice(0, 10);
|
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 getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
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()) => {
|
const isAllowedDeliverySlotDate = (dateKey, referenceDate = new Date()) => {
|
||||||
|
|
@ -65,15 +81,15 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
const deliveryTime = invitation.deliveryTime;
|
const deliveryTime = invitation.deliveryTime;
|
||||||
|
|
||||||
if (!rawSlots.length && !deliveryDate) {
|
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 todayKey = getBusinessTodayKey(referenceDate);
|
||||||
const tomorrow = addDaysToDateKey(todayKey, 1);
|
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||||
const dayAfter = addDaysToDateKey(todayKey, 2);
|
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||||
return [
|
return [
|
||||||
{ id: `slot-${tomorrow}-first`, date: tomorrow, time: "Первая половина дня" },
|
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||||
{ id: `slot-${tomorrow}-second`, date: tomorrow, time: "Вторая половина дня" },
|
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||||
{ id: `slot-${dayAfter}-first`, date: dayAfter, time: "Первая половина дня" },
|
{ id: `slot-${secondWorkday}-first`, date: secondWorkday, time: "Первая половина дня" },
|
||||||
{ id: `slot-${dayAfter}-second`, date: dayAfter, time: "Вторая половина дня" },
|
{ id: `slot-${secondWorkday}-second`, date: secondWorkday, time: "Вторая половина дня" },
|
||||||
].filter((s) => s.date);
|
].filter((s) => s.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,22 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstDay = formatCrimeaDate(addDays(now, 1));
|
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
||||||
const secondDay = formatCrimeaDate(addDays(now, 2));
|
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 [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
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 = (
|
const resolveRequestedSlot = (
|
||||||
invitation: {
|
invitation: {
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
|
|
@ -52,6 +59,11 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||||
|
if (isWeekendDate(deliveryDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
const slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
||||||
const availableSlots = invitation.available_slots || [];
|
const availableSlots = invitation.available_slots || [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -758,6 +758,11 @@ begin
|
||||||
raise exception 'Selected slot is not available';
|
raise exception 'Selected slot is not available';
|
||||||
end if;
|
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_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));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,20 @@ type ConfirmBody = {
|
||||||
token?: string;
|
token?: string;
|
||||||
deliveryDate?: string;
|
deliveryDate?: string;
|
||||||
deliveryTime?: string;
|
deliveryTime?: string;
|
||||||
|
deliveryType?: string;
|
||||||
|
pickupDate?: string;
|
||||||
|
pickupTimeSlot?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
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 = (
|
const resolveRequestedSlot = (
|
||||||
invitation: {
|
invitation: {
|
||||||
delivery_date?: string | null;
|
delivery_date?: string | null;
|
||||||
|
|
@ -36,6 +46,7 @@ const resolveRequestedSlot = (
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
) => {
|
) => {
|
||||||
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
const deliveryTime = String(body.deliveryTime || invitation.delivery_time || "").trim();
|
||||||
|
|
||||||
|
|
@ -43,6 +54,16 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
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 slotLabel = `${deliveryDate}, ${deliveryTime}`;
|
||||||
const availableSlots = invitation.available_slots || [];
|
const availableSlots = invitation.available_slots || [];
|
||||||
|
|
||||||
|
|
@ -50,7 +71,7 @@ const resolveRequestedSlot = (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { deliveryDate, deliveryTime };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.serve(async (request) => {
|
Deno.serve(async (request) => {
|
||||||
|
|
@ -127,10 +148,12 @@ Deno.serve(async (request) => {
|
||||||
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
return jsonResponse({ ok: false, error: "Invitation expired" }, 410, corsHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
|
|
||||||
if (invitation.order_group_id) {
|
if (invitation.order_group_id) {
|
||||||
const { data: currentGroup, error: groupError } = await supabase
|
const { data: currentGroup, error: groupError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.select("id, delivery_status")
|
.select("id, delivery_status, delivery_address, customer_address")
|
||||||
.eq("id", invitation.order_group_id)
|
.eq("id", invitation.order_group_id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|
@ -138,6 +161,14 @@ Deno.serve(async (request) => {
|
||||||
throw groupError;
|
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") {
|
if (!isActiveInvitationState(invitation.state) || currentGroup.delivery_status !== "pending_confirmation") {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
@ -177,15 +208,25 @@ Deno.serve(async (request) => {
|
||||||
throw invitationUpdateError;
|
throw invitationUpdateError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const groupUpdateData: Record<string, unknown> = {
|
||||||
|
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
|
const { error: groupUpdateError } = await supabase
|
||||||
.from("order_groups")
|
.from("order_groups")
|
||||||
.update({
|
.update(groupUpdateData)
|
||||||
delivery_status: "agreed",
|
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
|
||||||
notification_status: "confirmed",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", invitation.order_group_id);
|
.eq("id", invitation.order_group_id);
|
||||||
|
|
||||||
if (groupUpdateError) {
|
if (groupUpdateError) {
|
||||||
|
|
@ -197,10 +238,13 @@ Deno.serve(async (request) => {
|
||||||
order_group_id: invitation.order_group_id,
|
order_group_id: invitation.order_group_id,
|
||||||
action: "client_confirmed",
|
action: "client_confirmed",
|
||||||
old_value: currentGroup.delivery_status,
|
old_value: currentGroup.delivery_status,
|
||||||
new_value: "agreed",
|
new_value: effectiveDeliveryStatus,
|
||||||
details: {
|
details: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
source: "auto",
|
source: "auto",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -215,6 +259,9 @@ Deno.serve(async (request) => {
|
||||||
delivery_invitation_id: invitation.id,
|
delivery_invitation_id: invitation.id,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
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,
|
ok: true,
|
||||||
orderGroupId: invitation.order_group_id,
|
orderGroupId: invitation.order_group_id,
|
||||||
deliveryStatus: "agreed",
|
deliveryStatus: effectiveDeliveryStatus,
|
||||||
|
pickupCode: groupUpdateData.pickup_code || null,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
corsHeaders,
|
corsHeaders,
|
||||||
|
|
@ -314,6 +362,9 @@ Deno.serve(async (request) => {
|
||||||
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
new_delivery_agreement_status: orderUpdate?.deliveryAgreementStatus,
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
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: {
|
payload: {
|
||||||
delivery_date: requestedSlot.deliveryDate,
|
delivery_date: requestedSlot.deliveryDate,
|
||||||
delivery_time: requestedSlot.deliveryTime,
|
delivery_time: requestedSlot.deliveryTime,
|
||||||
|
delivery_type: deliveryType,
|
||||||
|
pickup_date: body.pickupDate || null,
|
||||||
|
pickup_time_slot: body.pickupTimeSlot || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue