feat(client): номер доставки и код выдачи + «Доставка» вместо «Счета»; в SMS — {order} и {code}

Клиентская страница согласования:
- приглашение теперь отдаёт deliveryType и pickupCode (public view) — до этого
  страница не знала, что заказ самовывозный, и говорила «Доставка заказа /
  Согласование доставки» даже для самовывоза; в шапке и hero-тексте выбор по типу
- в блоке с кодом: «Ваш номер доставки 010796» + «Ваш код выдачи 7598»
  (номер берём из состава заказа, «ТП\-010796» → «010796»)
- ссылка на заказ теперь «Доставка ТП-010796» вместо «Счет ТП\-010796»,
  экранированный дефис из обмена с 1С больше не показывается

SMS: в шаблоны добавились переменные {order}/{номер}/{номер_доставки} (номер
доставки) и {code}/{код}/{код_выдачи} (код выдачи) — работают и в шаблоне
кампании, и в тексте профиля города. Дефолтные тексты самовывоза (и значения в
БД, см. supabase/sms_pickup_code_placeholders.sql) теперь содержат номер и код;
в админке у полей появилась памятка по переменным и предпросмотр с подстановкой.

service-worker v118 → v119
This commit is contained in:
Hermes 2026-09-28 12:21:51 +02:00
parent b69e5172d6
commit d98c44c9fd
11 changed files with 268 additions and 42 deletions

View File

@ -1,8 +1,8 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) { if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v118"; const STATIC_CACHE = "construction-delivery-static-v119";
const RUNTIME_CACHE = "construction-delivery-runtime-v118"; const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"]; const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
// Only these are treated as cacheable static assets. Everything else // Only these are treated as cacheable static assets. Everything else

View File

@ -68,10 +68,16 @@ LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# Шаблоны живут в админке (sms_campaign_settings): # Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ # sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА # sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*. # Подстановки: {link}/{ссылка} — ссылка согласования,
# {order}/{номер}/{номер_доставки} — номер доставки (010796),
# {code}/{код}/{код_выдачи} — код выдачи (7598).
# Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}" DEFAULT_TEXT_DELIVERY = "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
DEFAULT_TEXT_PICKUP = "Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}" DEFAULT_TEXT_PICKUP = (
"Ваш заказ готов. Номер доставки {order}, код выдачи {code}. "
"Согласуйте дату самовывоза по ссылке: {link}"
)
DEFAULT_TEXT_NOTIFICATION = "Ваш заказ готов." DEFAULT_TEXT_NOTIFICATION = "Ваш заказ готов."
@ -79,6 +85,35 @@ def is_pickup_group(group):
return (group.get("delivery_type") or "delivery").strip().lower() == "pickup" return (group.get("delivery_type") or "delivery").strip().lower() == "pickup"
def first_order_digits(value):
"""Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
items = value if isinstance(value, (list, tuple)) else [value]
for item in items:
digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
if digits:
return digits
return ""
def apply_sms_placeholders(text, group, link):
"""Подстановка переменных шаблона в текст SMS."""
if not text:
return text
order = first_order_digits(group.get("order_numbers"))
code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
replacements = (
("{link}", link), ("{ссылка}", link),
("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
("{код_выдачи}", code), ("{код}", code), ("{code}", code),
)
result = text
for token, value in replacements:
result = result.replace(token, value)
return result.strip()
def build_sms_text(group, settings): def build_sms_text(group, settings):
"""Текст SMS по типу отгрузки: самовывоз → pickup-шаблон, доставка → обычный. """Текст SMS по типу отгрузки: самовывоз → pickup-шаблон, доставка → обычный.
@ -99,13 +134,13 @@ def build_sms_text(group, settings):
if mode == "notification": if mode == "notification":
if profile_text: if profile_text:
return profile_text return apply_sms_placeholders(profile_text, group, link)
# уведомление без ссылки: шаблон годится, только если в нём нет {link} # уведомление без ссылки: шаблон годится, только если в нём нет {link}
if template and "{link}" not in template: if template and "{link}" not in template:
return template return apply_sms_placeholders(template, group, link)
return DEFAULT_TEXT_NOTIFICATION return apply_sms_placeholders(DEFAULT_TEXT_NOTIFICATION, group, link)
return template.replace("{link}", link).strip() return apply_sms_placeholders(template, group, link)
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
@ -227,6 +262,7 @@ def get_groups_to_send(conn, max_attempts):
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.delivery_type, og.town, og.sms_attempts, og.delivery_type, og.town, og.sms_attempts,
og.order_numbers, og.pickup_code,
-- Профиль города (fallback на дефолтные значения если нет записи) -- Профиль города (fallback на дефолтные значения если нет записи)
COALESCE(scp.delivery_mode, 'link') as delivery_mode, COALESCE(scp.delivery_mode, 'link') as delivery_mode,
COALESCE(scp.delivery_text, '') as delivery_text, COALESCE(scp.delivery_text, '') as delivery_text,

View File

@ -55,18 +55,50 @@ LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# Шаблоны живут в админке (sms_campaign_settings): # Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ # sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА # sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*. # Подстановки: {link}/{ссылка} — ссылка согласования,
# {order}/{номер}/{номер_доставки} — номер доставки (010796),
# {code}/{код}/{код_выдачи} — код выдачи (7598).
# Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = ( DEFAULT_TEXT_DELIVERY = (
"Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. " "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. "
"Заберите заказ или согласуйте доставку: {link}" "Заберите заказ или согласуйте доставку: {link}"
) )
DEFAULT_TEXT_PICKUP = ( DEFAULT_TEXT_PICKUP = (
"Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. " "Заказ переведён на платное хранение. Номер доставки {order}, код выдачи {code}. "
"Заберите заказ со склада или согласуйте дату самовывоза: {link}" "Заберите со склада или согласуйте дату самовывоза: {link}"
) )
def first_order_digits(value):
"""Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
items = value if isinstance(value, (list, tuple)) else [value]
for item in items:
digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
if digits:
return digits
return ""
def apply_sms_placeholders(text, group, link):
"""Подстановка переменных шаблона в текст SMS."""
if not text:
return text
order = first_order_digits(group.get("order_numbers"))
code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
replacements = (
("{link}", link), ("{ссылка}", link),
("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
("{код_выдачи}", code), ("{код}", code), ("{code}", code),
)
result = text
for token, value in replacements:
result = result.replace(token, value)
return result.strip()
def build_sms_text(group, settings): def build_sms_text(group, settings):
"""Текст SMS о платном хранении по типу отгрузки.""" """Текст SMS о платном хранении по типу отгрузки."""
link = group.get("delivery_link") or "" link = group.get("delivery_link") or ""
@ -76,7 +108,7 @@ def build_sms_text(group, settings):
) or "").strip() ) or "").strip()
if not template: if not template:
template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
return template.replace("{link}", link).strip() return apply_sms_placeholders(template, group, link)
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
@ -222,7 +254,8 @@ def get_groups_to_send(conn):
cur.execute(""" cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.paid_storage_at, og.delivery_type, og.town og.paid_storage_at, og.delivery_type, og.town,
og.order_numbers, og.pickup_code
FROM order_groups og FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage' WHERE og.delivery_status = 'paid_storage'

View File

@ -67,10 +67,45 @@ LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
# Шаблоны живут в админке (sms_campaign_settings): # Шаблоны живут в админке (sms_campaign_settings):
# sms_text_template — текст для ДОСТАВКИ # sms_text_template — текст для ДОСТАВКИ
# sms_text_template_pickup — текст для САМОВЫВОЗА # sms_text_template_pickup — текст для САМОВЫВОЗА
# {link} подставляется ссылкой согласования. Пустой шаблон → DEFAULT_TEXT_*. # Подстановки: {link}/{ссылка} — ссылка согласования,
# {order}/{номер}/{номер_доставки} — номер доставки (010796),
# {code}/{код}/{код_выдачи} — код выдачи (7598).
# Пустой шаблон → DEFAULT_TEXT_*.
DEFAULT_TEXT_DELIVERY = "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}" DEFAULT_TEXT_DELIVERY = "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}"
DEFAULT_TEXT_PICKUP = "Ваш заказ готов к самовывозу. Выберите дату самовывоза по ссылке: {link}" DEFAULT_TEXT_PICKUP = (
"Напоминаем: номер доставки {order}, код выдачи {code}. "
"Выберите дату самовывоза по ссылке: {link}"
)
def first_order_digits(value):
"""Номер доставки из order_numbers («ТП\\-010722» → «010722»)."""
items = value if isinstance(value, (list, tuple)) else [value]
for item in items:
digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit())
if digits:
return digits
return ""
def apply_sms_placeholders(text, group, link):
"""Подстановка переменных шаблона в текст SMS."""
if not text:
return text
order = first_order_digits(group.get("order_numbers"))
code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit())
replacements = (
("{link}", link), ("{ссылка}", link),
("{номер_доставки}", order), ("{номер}", order), ("{order}", order),
("{код_выдачи}", code), ("{код}", code), ("{code}", code),
)
result = text
for token, value in replacements:
result = result.replace(token, value)
return result.strip()
def build_sms_text(group, settings): def build_sms_text(group, settings):
@ -82,7 +117,7 @@ def build_sms_text(group, settings):
) or "").strip() ) or "").strip()
if not template: if not template:
template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY
return template.replace("{link}", link).strip() return apply_sms_placeholders(template, group, link)
# ─── Логирование ───────────────────────────────────────────────────────────── # ─── Логирование ─────────────────────────────────────────────────────────────
@ -237,7 +272,7 @@ def get_groups_to_send(conn, max_attempts):
cur.execute(""" cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone, SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status, og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.delivery_type, og.town og.delivery_type, og.town, og.order_numbers, og.pickup_code
FROM order_groups og FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'pending_confirmation' WHERE og.delivery_status = 'pending_confirmation'

View File

@ -24,13 +24,23 @@ const HAS_SETTINGS = ["first_sms", "second_sms", "manual", "paid_storage"];
// ── SMS text templates (доставка / самовывоз) ──────────────────────────────── // ── SMS text templates (доставка / самовывоз) ────────────────────────────────
const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX"; const SMS_LINK_PREVIEW = "https://dost.supersamsev.ru/d/XXXXXX";
// Образцы значений для предпросмотра (как будет выглядеть SMS у клиента)
const SMS_PREVIEW_VALUES = [
["{номер_доставки}", "010796"], ["{номер}", "010796"], ["{order}", "010796"],
["{код_выдачи}", "7598"], ["{код}", "7598"], ["{code}", "7598"],
["{ссылка}", SMS_LINK_PREVIEW], ["{link}", SMS_LINK_PREVIEW],
];
const renderSmsPreview = (template, emptyLabel) => { const smsPreviewText = (template) => {
const text = (template || "").trim(); let text = (template || "").trim();
if (!text) return emptyLabel; SMS_PREVIEW_VALUES.forEach(([token, value]) => {
return text.replace("{link}", SMS_LINK_PREVIEW); text = text.split(token).join(value);
});
return text;
}; };
const renderSmsPreview = (template, emptyLabel) => smsPreviewText(template) || emptyLabel;
// ── Days of week ───────────────────────────────────────────────────────────── // ── Days of week ─────────────────────────────────────────────────────────────
const DAYS = [ const DAYS = [
{ num: 1, short: "Пн" }, { num: 1, short: "Пн" },
@ -512,7 +522,7 @@ export const SmsCampaignPanel = () => {
placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}" placeholder="Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}"
/> />
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {(settings.sms_text_template || "").replace("{link}", SMS_LINK_PREVIEW).length} символов Текущая длина: {smsPreviewText(settings.sms_text_template).length} символов
</div> </div>
</div> </div>
<div> <div>
@ -525,12 +535,15 @@ export const SmsCampaignPanel = () => {
placeholder="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}" placeholder="Ваш заказ готов. Согласуйте дату самовывоза по ссылке: {link}"
/> />
<div className="mt-1 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-1 text-[10px] text-[var(--color-text-muted)]">
Текущая длина: {(settings.sms_text_template_pickup || "").replace("{link}", SMS_LINK_PREVIEW).length} символов Текущая длина: {smsPreviewText(settings.sms_text_template_pickup).length} символов
</div> </div>
</div> </div>
</div> </div>
<div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]"> <div className="mt-2 rounded-lg bg-[var(--color-surface-strong)] px-3 py-2 text-[10px] text-[var(--color-text-muted)]">
Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый. {"{link}"} заменяется ссылкой согласования. Текст выбирается по типу отгрузки: самовывоз → правый шаблон, доставка → левый.
<div className="mt-1">
Переменные: {"{link}"} — ссылка согласования, {"{order}"} — номер доставки (можно {"{номер}"}), {"{code}"} — код выдачи (можно {"{код}"}).
</div>
<div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div> <div className="mt-1">📦 Доставка: {renderSmsPreview(settings.sms_text_template, "— пусто, уйдёт стандартный текст кампании")}</div>
<div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div> <div>🏪 Самовывоз: {renderSmsPreview(settings.sms_text_template_pickup, "— пусто, уйдёт стандартный текст кампании")}</div>
<div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div> <div className="mt-1">Если у города в «SMS по городам» задан свой текст в режиме «Уведомление» — уходит он, и тогда шаблон применяется только если в нём нет {"{link}"}.</div>

View File

@ -84,7 +84,7 @@ describe("DeliveryChoiceFlow", () => {
/>, />,
); );
expect(markup).toContain("Счет CD-240032"); expect(markup).toContain("Доставка CD-240032");
expect(markup).not.toContain("Александр Савин"); expect(markup).not.toContain("Александр Савин");
}); });
}); });

View File

@ -20,20 +20,42 @@ const getOrderItemNames = (invitation) => {
.filter(Boolean); .filter(Boolean);
}; };
// Обмен с 1С экранирует дефис («ТП\-010796») — клиенту показываем «ТП-010796».
const unescapeOncDashes = (value) => String(value || "").replace(/\\/g, "");
export const getInvitationReferenceLabel = (invitation) => { export const getInvitationReferenceLabel = (invitation) => {
const invoiceNumbers = [...new Set(getOrderItemNames(invitation))]; const invoiceNumbers = [...new Set(getOrderItemNames(invitation))].map(unescapeOncDashes);
if (invoiceNumbers.length === 1) { if (invoiceNumbers.length === 1) {
return `Счет ${invoiceNumbers[0]}`; return `Доставка ${invoiceNumbers[0]}`;
} }
if (invoiceNumbers.length > 1) { if (invoiceNumbers.length > 1) {
return `Счета: ${invoiceNumbers.join(", ")}`; return `Доставки: ${invoiceNumbers.join(", ")}`;
} }
const orderNumber = typeof invitation?.orderNumber === "string" ? invitation.orderNumber.trim() : ""; const orderNumber = typeof invitation?.orderNumber === "string"
? unescapeOncDashes(invitation.orderNumber.trim())
: "";
if (orderNumber) { if (orderNumber) {
return `Счет ${orderNumber}`; return `Доставка ${orderNumber}`;
} }
return "Счет —"; return "Доставка —";
};
/**
* Номер доставки для клиента: «ТП\-010796» → «010796».
* Сначала берём номер заказа из состава, затем orderNumber/orderNumbers приглашения.
*/
export const getDeliveryNumberDigits = (invitation) => {
const fromItems = getOrderItemNames(invitation)[0] || "";
const fromOrderNumbers = Array.isArray(invitation?.orderNumbers)
? invitation.orderNumbers[0] || ""
: "";
const fromOrderNumber = typeof invitation?.orderNumber === "string"
? invitation.orderNumber
: "";
const source = fromItems || fromOrderNumbers || fromOrderNumber;
return unescapeOncDashes(source).replace(/[^0-9]/g, "");
}; };

View File

@ -4,7 +4,7 @@ import { DeliveryChoiceFlow } from "../components/client/DeliveryChoiceFlow";
import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker"; import { DeliverySlotsPicker } from "../components/client/DeliverySlotsPicker";
import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker"; import { PickupSlotsPicker } from "../components/client/PickupSlotsPicker";
import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel"; import { OrderCompositionPanel } from "../components/client/OrderCompositionPanel";
import { getInvitationReferenceLabel } from "../components/client/invitationReference"; import { getDeliveryNumberDigits, getInvitationReferenceLabel } from "../components/client/invitationReference";
import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice"; import { DeliveryStateNotice } from "../components/client/DeliveryStateNotice";
import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner"; import { PushSubscriptionBanner } from "../components/client/PushSubscriptionBanner";
import { PwaInstallCard } from "../components/client/PwaInstallCard"; import { PwaInstallCard } from "../components/client/PwaInstallCard";
@ -299,13 +299,25 @@ export const buildSelectedSlotFromInvitation = (invitation, slots = []) => {
}; };
}; };
export const getClientDeliveryHeroDescription = (isActiveState, isChoiceSaved) => { export const getClientDeliveryHeroDescription = (
isActiveState,
isChoiceSaved,
deliveryType = "delivery",
) => {
if (isChoiceSaved) { if (isChoiceSaved) {
return ""; return "";
} }
return isActiveState const isPickup = deliveryType === "pickup";
? "Вам предложены варианты доставки. Выберите удобную дату и время."
if (isActiveState) {
return isPickup
? "Вам предложены варианты самовывоза. Выберите удобную дату и время."
: "Вам предложены варианты доставки. Выберите удобную дату и время.";
}
return isPickup
? "По этому заказу согласование самовывоза завершено или передано логисту."
: "По этому заказу согласование доставки завершено или передано логисту."; : "По этому заказу согласование доставки завершено или передано логисту.";
}; };
@ -399,6 +411,10 @@ export const ClientDeliveryPage = () => {
const invitationState = invitation?.state || "awaiting_choice"; const invitationState = invitation?.state || "awaiting_choice";
const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState); const isActiveState = ["awaiting_choice", "opened", "reminder_sent"].includes(invitationState);
// Тип отгрузки приходит из приглашения: для самовывоза страница говорит «самовывоз».
const orderIsPickup = invitation?.deliveryType === "pickup";
const deliveryNumberDigits = getDeliveryNumberDigits(invitation);
const orderTypeEyebrow = orderIsPickup ? "Самовывоз заказа" : "Доставка заказа";
const invitationSelectedSlot = isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots); const invitationSelectedSlot = isActiveState ? null : buildSelectedSlotFromInvitation(invitation, slots);
@ -407,7 +423,11 @@ export const ClientDeliveryPage = () => {
const savedChoiceLabel = effectiveSelectedSlot const savedChoiceLabel = effectiveSelectedSlot
? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}` ? `${formatDeliveryDate(effectiveSelectedSlot.date)} / ${effectiveSelectedSlot.time}`
: ""; : "";
const heroDescription = getClientDeliveryHeroDescription(isActiveState, isChoiceSaved); const heroDescription = getClientDeliveryHeroDescription(
isActiveState,
isChoiceSaved,
invitation?.deliveryType,
);
const handleSaveChoice = async () => { const handleSaveChoice = async () => {
if (!token) { if (!token) {
@ -558,8 +578,10 @@ export const ClientDeliveryPage = () => {
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{!isChoiceSaved ? ( {!isChoiceSaved ? (
<Panel className="space-y-3 p-5 sm:p-6"> <Panel className="space-y-3 p-5 sm:p-6">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">Доставка заказа</p> <p className="text-sm uppercase tracking-[0.24em] text-[var(--color-text-muted)]">{orderTypeEyebrow}</p>
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">Согласование доставки</h1> <h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
{orderIsPickup ? "Согласование самовывоза" : "Согласование доставки"}
</h1>
{heroDescription ? ( {heroDescription ? (
<p className="text-sm leading-6 text-[var(--color-text-muted)]"> <p className="text-sm leading-6 text-[var(--color-text-muted)]">
{heroDescription} {heroDescription}
@ -591,7 +613,10 @@ export const ClientDeliveryPage = () => {
</p> </p>
{(invitation?.pickupCode || invitation?.pickup_code) ? ( {(invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="mt-3 rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center"> <div className="mt-3 rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p> {deliveryNumberDigits ? (
<p className="text-sm font-semibold text-[var(--color-text)]">Ваш номер доставки {deliveryNumberDigits}</p>
) : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Ваш код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p> <p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p> <p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div> </div>
@ -694,7 +719,10 @@ export const ClientDeliveryPage = () => {
{activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? ( {activeTab === TAB_PICKUP && (invitation?.pickupCode || invitation?.pickup_code) ? (
<div className="rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center"> <div className="rounded-2xl border-2 border-[var(--color-accent)] bg-[var(--color-accent-soft)] p-4 text-center">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Код выдачи</p> {deliveryNumberDigits ? (
<p className="text-sm font-semibold text-[var(--color-text)]">Ваш номер доставки {deliveryNumberDigits}</p>
) : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Ваш код выдачи</p>
<p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p> <p className="mt-1 text-3xl font-bold tracking-[0.4em] text-[var(--color-accent)]">{invitation.pickupCode || invitation.pickup_code}</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p> <p className="mt-2 text-xs text-[var(--color-text-muted)]">Покажите этот код при получении заказа</p>
</div> </div>

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getInvitationReferenceLabel } from "../components/client/invitationReference"; import { getDeliveryNumberDigits, getInvitationReferenceLabel } from "../components/client/invitationReference";
import { import {
buildDeliveryConfirmationPayload, buildDeliveryConfirmationPayload,
buildSelectedSlotFromInvitation, buildSelectedSlotFromInvitation,
@ -176,7 +176,16 @@ describe("ClientDeliveryPage helpers", () => {
{ name: "СФ Т\\ЕА-28700", quantity: "" }, { name: "СФ Т\\ЕА-28700", quantity: "" },
], ],
}), }),
).toBe("Счета: СФ Т\\ЕА-28687, СФ Т\\ЕА-28700"); ).toBe("Доставки: СФ ТЕА-28687, СФ ТЕА-28700");
});
it("берёт номер доставки из состава заказа: «ТП\\-010796» → «010796»", () => {
expect(
getDeliveryNumberDigits({ orderItems: [{ name: "ТП\\-010796", quantity: "" }] }),
).toBe("010796");
expect(getDeliveryNumberDigits({ orderNumbers: ["ТП\\-010557", "ТП\\-010599"] })).toBe("010557");
expect(getDeliveryNumberDigits({ orderNumber: "9788324113|28.09.2026" })).toBe("978832411328092026");
expect(getDeliveryNumberDigits({})).toBe("");
}); });
it("hides the hero helper text after the client saves the choice", () => { it("hides the hero helper text after the client saves the choice", () => {
@ -189,6 +198,16 @@ describe("ClientDeliveryPage helpers", () => {
); );
}); });
it("говорит про самовывоз, если отгрузка самовывозом", () => {
expect(getClientDeliveryHeroDescription(true, false, "pickup")).toBe(
"Вам предложены варианты самовывоза. Выберите удобную дату и время.",
);
expect(getClientDeliveryHeroDescription(false, false, "pickup")).toBe(
"По этому заказу согласование самовывоза завершено или передано логисту.",
);
expect(getClientDeliveryHeroDescription(true, true, "pickup")).toBe("");
});
it("skips Sunday when generating fallback delivery slots from Friday", () => { 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) // 2026-04-17 is Friday → next workday = Saturday 18, then Monday 20 (skip Sunday 19)
const slots = groupSlotsFromInvitation( const slots = groupSlotsFromInvitation(

View File

@ -242,6 +242,8 @@ export type OrderGroupInvitationSource = {
delivery_status?: string | null; delivery_status?: string | null;
delivery_link?: string | null; delivery_link?: string | null;
source_orders?: unknown[] | null; source_orders?: unknown[] | null;
delivery_type?: string | null;
pickup_code?: string | null;
}; };
export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => { export const isInvitationExpired = (invitation: DeliveryInvitationRecord, now = new Date()) => {
if (invitation.revoked_at) { if (invitation.revoked_at) {
@ -325,6 +327,11 @@ export const buildPublicOrderGroupInvitationView = (
customerName: maskCustomerName(customerName), customerName: maskCustomerName(customerName),
customerPhone: maskPhoneNumber(customerPhone), customerPhone: maskPhoneNumber(customerPhone),
orderItems, orderItems,
// Тип отгрузки и код выдачи — клиентская страница показывает «самовывоз»
// и номер доставки с кодом выдачи по этим полям.
deliveryType: group.delivery_type || null,
pickupCode: group.pickup_code || null,
orderNumbers,
availableSlots: invitation.available_slots || [], availableSlots: invitation.available_slots || [],
deliveryDate: invitation.delivery_date || null, deliveryDate: invitation.delivery_date || null,
deliveryTime: invitation.delivery_time || null, deliveryTime: invitation.delivery_time || null,

View File

@ -0,0 +1,33 @@
-- Код выдачи и номер доставки в SMS для самовывоза
-- 2026-09-28
--
-- Переменные шаблона (sms_text_template / sms_text_template_pickup):
-- {link} / {ссылка} — ссылка согласования
-- {order} / {номер} — номер доставки без префикса (010796)
-- {code} / {код} — код выдачи (7598)
-- Подстановку делает скрипт кампании (apply_sms_placeholders).
UPDATE sms_campaign_settings SET
sms_text_template_pickup = 'Ваш заказ готов. Номер доставки {order}, код выдачи {code}. Согласуйте дату самовывоза по ссылке: {link}'
WHERE campaign_type = 'first_sms';
UPDATE sms_campaign_settings SET
sms_text_template_pickup = 'Напоминаем: номер доставки {order}, код выдачи {code}. Выберите дату самовывоза по ссылке: {link}'
WHERE campaign_type = 'second_sms';
UPDATE sms_campaign_settings SET
sms_text_template_pickup = 'Заказ переведён на платное хранение. Номер доставки {order}, код выдачи {code}. Заберите со склада или согласуйте дату самовывоза: {link}'
WHERE campaign_type = 'paid_storage';
-- Текст самовывоза для городов в режиме «Уведомление» (сейчас Ялта):
-- переменные работают и в профиле города.
UPDATE sms_city_profiles SET
pickup_text = 'Ваш заказ готов. Номер доставки {order}, код выдачи {code}. Заберите со склада.',
updated_at = NOW()
WHERE pickup_text IS NOT NULL
AND pickup_text <> ''
AND pickup_text NOT LIKE '%{code}%';
-- Проверка:
-- SELECT campaign_type, sms_text_template_pickup FROM sms_campaign_settings ORDER BY 1;
-- SELECT city, pickup_text FROM sms_city_profiles;