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:
root 2026-06-23 04:56:49 +00:00
parent d04901d296
commit b74add32ef
7 changed files with 151 additions and 21 deletions

View File

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

View File

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

View File

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

View File

@ -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}, Первая половина дня`,

View File

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

View File

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

View File

@ -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 { error: groupUpdateError } = await supabase
.from("order_groups")
.update({
delivery_status: "agreed",
const groupUpdateData: Record<string, unknown> = {
delivery_status: effectiveDeliveryStatus,
delivery_date: requestedSlot.deliveryDate,
delivery_time: requestedSlot.deliveryTime,
notification_status: "confirmed",
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(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,
},
});