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';
|
||||
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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
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}, Первая половина дня`,
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
.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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue