830 lines
35 KiB
JavaScript
830 lines
35 KiB
JavaScript
const normalizeDate = (value) => (value ? String(value) : "");
|
||
|
||
// Слот заказа: у самовывоза дата и время выдачи лежат в pickup_*, у доставки — в delivery_*.
|
||
// Панели и фильтры раньше смотрели только на delivery_*, поэтому у самовывоза колонка даты
|
||
// пустовала, а фильтр по периоду выкидывал такие заказы.
|
||
export const isPickupGroup = (group) =>
|
||
(group?.deliveryType || group?.delivery_type) === "pickup" ||
|
||
(group?.deliveryStatus || group?.delivery_status) === "pickup";
|
||
|
||
export const getGroupSlotDate = (group) =>
|
||
normalizeDate(
|
||
isPickupGroup(group)
|
||
? group?.pickupDate || group?.pickup_date || ""
|
||
: group?.deliveryDate || group?.delivery_date || "",
|
||
);
|
||
|
||
export const getGroupSlotTime = (group) =>
|
||
(isPickupGroup(group)
|
||
? group?.pickupTimeSlot || group?.pickup_time_slot || ""
|
||
: group?.deliveryTime || group?.delivery_time || "") || "";
|
||
|
||
// Дата, по которой группируют и фильтруют реестр: согласованный слот, иначе дата заказа.
|
||
const getDeliveryDate = (group) => normalizeDate(getGroupSlotDate(group) || group.customerDate || "");
|
||
|
||
export const DELIVERY_GROUP_STATUS_LABELS = {
|
||
pending_confirmation: "Ожидает согласования",
|
||
manual_confirmation_required: "Взят в ручное управление",
|
||
requires_address: "Требуется адрес",
|
||
agreed: "Согласовано",
|
||
driver_assigned: "Назначен водитель",
|
||
loaded: "Загружено",
|
||
on_route: "В пути",
|
||
delivered: "Доставлено",
|
||
picked_up: "Вывезено",
|
||
problem: "Проблема",
|
||
paid_storage: "Платное хранение",
|
||
pickup: "Самовывоз",
|
||
cancelled: "Отменено",
|
||
};
|
||
|
||
export const NOTIFICATION_STATUS_LABELS = {
|
||
not_started: "",
|
||
link_ready: "Ссылка готова",
|
||
first_sms_sent: "1-е приглашение отправлено",
|
||
second_sms_sent: "2-е приглашение отправлено",
|
||
send_failed: "Ошибка отправки",
|
||
confirmed: "Согласовано",
|
||
manual_required: "Требуется ручное подтверждение",
|
||
};
|
||
|
||
export const DRIVER_VISIBLE_DELIVERY_STATUSES = [
|
||
"agreed",
|
||
"driver_assigned",
|
||
"loaded",
|
||
"on_route",
|
||
"problem",
|
||
"delivered",
|
||
"picked_up",
|
||
"paid_storage",
|
||
];
|
||
|
||
export const DRIVER_ACTIVE_DELIVERY_STATUSES = ["driver_assigned", "problem"];
|
||
|
||
const HALF_DAY_LABELS = {
|
||
morning: "Первая половина дня",
|
||
afternoon: "Вторая половина дня",
|
||
};
|
||
|
||
const normalizeDeliveryHalfDayLabel = (value) => {
|
||
const normalized = normalizeDate(value).trim();
|
||
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
|
||
const lower = normalized.toLowerCase();
|
||
|
||
if (lower.includes("до обеда") || lower.includes("первая половина дня") || lower.includes("утро")) {
|
||
return HALF_DAY_LABELS.morning;
|
||
}
|
||
|
||
if (lower.includes("после обеда") || lower.includes("вторая половина дня") || lower.includes("вечер")) {
|
||
return HALF_DAY_LABELS.afternoon;
|
||
}
|
||
|
||
return "";
|
||
};
|
||
|
||
const parseJsonIfNeeded = (value) => {
|
||
if (typeof value !== "string") {
|
||
return value;
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return value;
|
||
}
|
||
};
|
||
|
||
const findDeliveryHalfDayInValue = (value) => {
|
||
const parsedValue = parseJsonIfNeeded(value);
|
||
|
||
if (Array.isArray(parsedValue)) {
|
||
for (const item of parsedValue) {
|
||
const match = findDeliveryHalfDayInValue(item);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
if (parsedValue && typeof parsedValue === "object") {
|
||
const candidates = [
|
||
parsedValue.deliveryTime,
|
||
parsedValue.delivery_time,
|
||
parsedValue.time,
|
||
parsedValue.deliveryHalfDay,
|
||
parsedValue.delivery_half_day,
|
||
parsedValue.window,
|
||
parsedValue.deliveryWindow,
|
||
parsedValue.delivery_window,
|
||
parsedValue.slot?.time,
|
||
parsedValue.deliverySlot?.time,
|
||
];
|
||
|
||
for (const candidate of candidates) {
|
||
const match = normalizeDeliveryHalfDayLabel(candidate);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
|
||
for (const nestedValue of Object.values(parsedValue)) {
|
||
const match = findDeliveryHalfDayInValue(nestedValue);
|
||
if (match) {
|
||
return match;
|
||
}
|
||
}
|
||
}
|
||
|
||
return normalizeDeliveryHalfDayLabel(parsedValue);
|
||
};
|
||
|
||
export const getOrderGroupDeliveryHalfDay = (group) =>
|
||
normalizeDeliveryHalfDayLabel(
|
||
group?.deliveryHalfDay ||
|
||
group?.deliveryTime ||
|
||
group?.deliveryWindow ||
|
||
findDeliveryHalfDayInValue(group?.sourceOrders),
|
||
);
|
||
|
||
export const isOrderGroupAgreedForDelivery = (group) => {
|
||
if (!group) {
|
||
return false;
|
||
}
|
||
|
||
return isOrderGroupVisibleToDriver(group);
|
||
};
|
||
|
||
export const getOrderGroupDeliveryStatusLabel = (status) =>
|
||
DELIVERY_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||
|
||
// Status values that represent a delivery state (not SMS/notification state)
|
||
const DELIVERY_STATUS_VALUES = new Set([
|
||
"pending_confirmation", "agreed", "driver_assigned", "loaded", "on_route",
|
||
"delivered", "picked_up", "pickup", "requires_address", "problem",
|
||
"cancelled", "paid_storage", "address_required", "manual_confirmation_required",
|
||
]);
|
||
|
||
export const getOrderGroupDisplayStatusLabel = (group) => {
|
||
const statusCol = group?.status;
|
||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||
|
||
// When auto-SMS failed and logistics hasn't taken action yet → show as a todo item
|
||
const isManualRequired = notificationStatus === "manual_required";
|
||
const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required";
|
||
if (isManualRequired && isStillPending) {
|
||
return "Требуется ручное управление";
|
||
}
|
||
|
||
// Primary: status column (now synced with delivery_status)
|
||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||
return getOrderGroupDeliveryStatusLabel(statusCol);
|
||
}
|
||
|
||
// Fallback: delivery_status (for pending/manual_confirmation groups)
|
||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||
return getOrderGroupDeliveryStatusLabel(deliveryStatus);
|
||
}
|
||
|
||
const notificationLabel = NOTIFICATION_STATUS_LABELS[notificationStatus];
|
||
if (notificationLabel && notificationStatus !== "link_ready" && notificationStatus !== "not_started") {
|
||
return notificationLabel;
|
||
}
|
||
|
||
return getOrderGroupStatusLabel(statusCol);
|
||
};
|
||
|
||
export const getOrderGroupDisplayStatusValue = (group) => {
|
||
const statusCol = group?.status;
|
||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||
|
||
// Unify manual_required into a single bucket regardless of delivery_status detail
|
||
const isManualRequired = notificationStatus === "manual_required";
|
||
const isStillPending = !deliveryStatus || deliveryStatus === "pending_confirmation" || deliveryStatus === "manual_confirmation_required";
|
||
if (isManualRequired && isStillPending) {
|
||
return "status:manual_required";
|
||
}
|
||
|
||
// Primary: status column (now synced with delivery_status)
|
||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||
return `delivery:${statusCol}`;
|
||
}
|
||
|
||
// Fallback: delivery_status
|
||
if (deliveryStatus && deliveryStatus !== "pending_confirmation" && deliveryStatus !== "manual_confirmation_required") {
|
||
return `delivery:${deliveryStatus}`;
|
||
}
|
||
|
||
return `status:${statusCol || "unknown"}`;
|
||
};
|
||
|
||
export const isOrderGroupVisibleToDriver = (group) => {
|
||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status || "pending_confirmation";
|
||
return DRIVER_VISIBLE_DELIVERY_STATUSES.includes(deliveryStatus);
|
||
};
|
||
|
||
export const parseGroupDate = (value) => {
|
||
const normalized = normalizeDate(value);
|
||
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
|
||
const isoDateMatch = normalized.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (isoDateMatch) {
|
||
return new Date(normalized);
|
||
}
|
||
|
||
const shortDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{2})$/);
|
||
if (shortDateMatch) {
|
||
const [, day, month, year] = shortDateMatch;
|
||
return new Date(Date.UTC(Number(`20${year}`), Number(month) - 1, Number(day)));
|
||
}
|
||
|
||
const fullDateMatch = normalized.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||
if (fullDateMatch) {
|
||
const [, day, month, year] = fullDateMatch;
|
||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||
}
|
||
|
||
const dotSeparatedMatch = normalized.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||
if (dotSeparatedMatch) {
|
||
const [, day, month, year] = dotSeparatedMatch;
|
||
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||
}
|
||
|
||
const parsed = new Date(normalized);
|
||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||
};
|
||
|
||
/**
|
||
* SMS status used for both table highlighting and filtering.
|
||
* Returns "not_sent" | "sent" | "delivered" | "error".
|
||
*/
|
||
export const getSmsStatus = (group) => {
|
||
if (!group) return "not_sent";
|
||
const firstSms = group.firstSmsSentAt || group.first_sms_sent_at;
|
||
const secondSms = group.secondSmsSentAt || group.second_sms_sent_at;
|
||
const lastError = group.lastSmsError;
|
||
// Sent an SMS (1st or 2nd) and it has a recorded delivery error → error.
|
||
if ((firstSms || secondSms) && lastError) return "error";
|
||
// SMS should be expected but not sent at all yet.
|
||
if (!firstSms && !secondSms) return "not_sent";
|
||
// Sent but no error recorded — assume delivered / in progress.
|
||
return "delivered";
|
||
};
|
||
|
||
// Для обратной совместимости
|
||
export const getSmsProblemTone = (group) => {
|
||
const s = getSmsStatus(group);
|
||
if (s === "error") return "not_delivered";
|
||
if (s === "not_sent") return "not_sent";
|
||
return "ok";
|
||
};
|
||
|
||
// ─── Статус SMS: человеческие значения для фильтра ─────────────────
|
||
export const SMS_STATUS_META = {
|
||
not_sent: { label: "Не отправлено", tone: "neutral" },
|
||
sent: { label: "Отправлено", tone: "info" },
|
||
delivered: { label: "Доставлено", tone: "accent" },
|
||
error: { label: "Ошибка", tone: "danger" },
|
||
};
|
||
|
||
// ─── Причина неудачной отправки SMS ────────────────────────────────
|
||
// Коды SMS-сервиса (первая строка ответа) → человеческая причина.
|
||
// В логе лежит только цифра, поэтому расшифровываем её здесь.
|
||
export const SMS_ERROR_REASONS = {
|
||
"100": "сообщение в очереди SMS-сервиса",
|
||
"101": "сообщение передано оператору",
|
||
"102": "сообщение в пути",
|
||
"104": "истёк срок доставки сообщения",
|
||
"105": "сообщение удалено оператором",
|
||
"106": "сбой на телефоне абонента",
|
||
"107": "оператор не сообщил причину",
|
||
"108": "оператор отклонил сообщение",
|
||
"130": "лимит сообщений на этот номер в сутки",
|
||
"131": "лимит одинаковых сообщений в минуту",
|
||
"132": "лимит одинаковых сообщений в сутки",
|
||
"200": "неверный ключ доступа к SMS-сервису",
|
||
"201": "на счёте SMS-сервиса закончились средства",
|
||
"202": "неверный номер получателя",
|
||
"203": "пустой текст сообщения",
|
||
"230": "исчерпан общий суточный лимит аккаунта",
|
||
"231": "лимит одинаковых сообщений в минуту",
|
||
"232": "лимит одинаковых сообщений в сутки",
|
||
};
|
||
|
||
// Код нехватки средств — по нему показываем отдельное предупреждение.
|
||
export const SMS_FUNDS_ERROR_CODE = "201";
|
||
|
||
const smsErrorCode = (log) => {
|
||
if (!log) return "";
|
||
const direct = String(log.sms_code ?? "").trim();
|
||
if (direct) return direct.match(/\d{3}/)?.[0] || direct;
|
||
const fromText = String(log.error_message ?? "").trim();
|
||
return fromText.match(/\d{3}/)?.[0] || "";
|
||
};
|
||
|
||
/**
|
||
* Причина, по которой SMS не ушла — словами. Принимает строку лога
|
||
* (sms_campaign_log) либо значение lastSmsError у группы.
|
||
*/
|
||
export const getSmsFailureReason = (log) => {
|
||
if (!log) return null;
|
||
// В некоторых местах причина лежит просто строкой: "201" или "201\n"
|
||
if (typeof log === "string") {
|
||
const text = log.trim();
|
||
const onlyCode = text.match(/^(\d{3})$/)?.[1];
|
||
if (onlyCode && SMS_ERROR_REASONS[onlyCode]) return SMS_ERROR_REASONS[onlyCode];
|
||
// Технические строки из планировщика вида «Not delivered after 2 attempts
|
||
// (code=106)» переводим в человеческие: код объясняем словами.
|
||
const embedded = text.match(/code\s*=\s*(\d{3})/i)?.[1];
|
||
if (embedded && SMS_ERROR_REASONS[embedded]) {
|
||
return `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`;
|
||
}
|
||
// «Не доставлено после N попыток» без кода — говорим так, как есть.
|
||
const attempts = text.match(/after\s+(\d+)\s+attempts?/i);
|
||
if (/not delivered|delivery failed|failed after/i.test(text)) {
|
||
return attempts
|
||
? `клиент не получил сообщение за ${attempts[1]} попытки — телефон недоступен или оператор не доставил`
|
||
: "клиент не получил сообщение — телефон недоступен или оператор не доставил";
|
||
}
|
||
if (/limit exceeded/i.test(text)) {
|
||
return embedded && SMS_ERROR_REASONS[embedded]
|
||
? `${SMS_ERROR_REASONS[embedded]} (код ${embedded})`
|
||
: "сработал лимит отправки — SMS-сервис временно блокирует рассылку";
|
||
}
|
||
return text.slice(0, 120) || null;
|
||
}
|
||
const code = smsErrorCode(log);
|
||
if (code && SMS_ERROR_REASONS[code]) return SMS_ERROR_REASONS[code];
|
||
const raw = String(log.error_message ?? "").trim();
|
||
if (code) return `код ${code}`;
|
||
if (!raw) return null;
|
||
return raw.slice(0, 120);
|
||
};
|
||
|
||
/** Нехватка средств на счёте SMS-сервиса — самая частая и заметная причина. */
|
||
export const isSmsFundsError = (log) => smsErrorCode(log) === SMS_FUNDS_ERROR_CODE;
|
||
|
||
// ─── Автодозвон: единый человеческий статус (по аналогии со статусом SMS) ──
|
||
// Принимает массив autocall_log (отсортированный по created_at desc) или null.
|
||
// ВАЖНО: те же значения приходят из базы (функция autocall_status(order_groups)) —
|
||
// набор ключей менять только вместе с ней, иначе фильтр и бейдж разойдутся.
|
||
// Это единственный источник подписей: их берут и бейдж в таблице, и фильтры.
|
||
export const AUTOCALL_STATUS_META = {
|
||
not_called: { label: "Не звонили", short: "звонка не было", tone: "neutral", icon: "🤖" },
|
||
confirmed: { label: "Согласовано", short: "согласовано", tone: "accent", icon: "✅" },
|
||
no_answer: { label: "Не дозвонились", short: "не дозвонились", tone: "danger", icon: "📵" },
|
||
no_input_day: { label: "Не выбрал день", short: "не выбрал день", tone: "warning", icon: "📅" },
|
||
no_input_half: { label: "Не выбрал время", short: "не выбрал время", tone: "warning", icon: "🕐" },
|
||
no_input_both: { label: "Не выбрал день и время", short: "не выбрал день и время", tone: "warning", icon: "🤐" },
|
||
manual: { label: "Нужен менеджер", short: "нужен менеджер", tone: "warning", icon: "🆘" },
|
||
opt_out: { label: "Не звонить", short: "не звонить", tone: "danger", icon: "🚫" },
|
||
excluded: { label: "Не звоним (исключение)", short: "исключение", tone: "neutral", icon: "🚫" },
|
||
// Локация выключена для звонков в админке (Настройки → Коммуникации → Автодозвон → Локации).
|
||
// Считается в БД (autocall_status), на SMS не влияет.
|
||
city_disabled: { label: "Не звоним (город)", short: "не звоним", tone: "neutral", icon: "🚫" },
|
||
};
|
||
|
||
/** Порядок для выпадающих списков — один список на всё приложение. */
|
||
export const AUTOCALL_STATUS_OPTIONS = [
|
||
{ value: "not_called", label: "Не звонили" },
|
||
{ value: "confirmed", label: "Согласовано" },
|
||
{ value: "no_answer", label: "Не дозвонились" },
|
||
{ value: "no_input_day", label: "Не выбрал день" },
|
||
{ value: "no_input_half", label: "Не выбрал время" },
|
||
{ value: "no_input_both", label: "Не выбрал день и время" },
|
||
{ value: "manual", label: "Нужен менеджер" },
|
||
{ value: "opt_out", label: "Не звонить (запрет вручную)" },
|
||
{ value: "excluded", label: "Не звоним (исключение)" },
|
||
{ value: "city_disabled", label: "Не звоним (город)" },
|
||
];
|
||
|
||
/**
|
||
* Что именно клиент не выбрал: день, время или и то и другое.
|
||
*
|
||
* Считаем по всем звонкам заказа, а не только по последнему: если в одном
|
||
* звонке клиент не выбрал день, а в другом день выбрал, но не успел время —
|
||
* честнее сказать «не выбрал день и время», чем показывать половину картины.
|
||
*/
|
||
export const resolveNoInputKind = (calls = []) => {
|
||
const outcomes = new Set((calls || []).map((c) => c?.outcome));
|
||
const day = outcomes.has("no_input_day");
|
||
const half = outcomes.has("no_input_half");
|
||
if (day && half) return "no_input_both";
|
||
if (half) return "no_input_half";
|
||
if (day) return "no_input_day";
|
||
return null;
|
||
};
|
||
|
||
/**
|
||
* Локация выключена для звонков (Настройки → Коммуникации → Автодозвон → 📍 Локации).
|
||
* Источник — то же значение, что считает БД (`autocall_status` = 'city_disabled'),
|
||
* плюс явный флаг, если объект пришёл не из реестра.
|
||
*/
|
||
export const isCityCallsDisabled = (group) =>
|
||
(group?.autocall_status || group?.autocallStatus) === "city_disabled" ||
|
||
Boolean(group?.cityCallsDisabled ?? group?.city_calls_disabled);
|
||
|
||
export const resolveAutocallStatus = (calls = [], { optOut = false, exclusionReason = null, cityDisabled = false } = {}) => {
|
||
if (optOut) return "opt_out";
|
||
// Локация выключена для звонков: робот по ней не набирает вообще — это важнее
|
||
// частного исключения по номеру.
|
||
if (cityDisabled) return "city_disabled";
|
||
if (exclusionReason) return "excluded";
|
||
if (!Array.isArray(calls) || calls.length === 0) return "not_called";
|
||
const last = calls[0];
|
||
if (["confirmed", "confirmed_silent", "confirmed_kept_previous"].includes(last.outcome)) return "confirmed";
|
||
if (last.outcome === "no_answer") return "no_answer";
|
||
if (["no_input_day", "no_input_half"].includes(last.outcome)) {
|
||
return resolveNoInputKind(calls) || "no_input_day";
|
||
}
|
||
if (["operator", "error", "invalid_number"].includes(last.outcome)) return "manual";
|
||
return "not_called";
|
||
};
|
||
|
||
// ─── Как согласовали дату: канал подтверждения ──────────────────────────────
|
||
// Владелец: «нужно видеть, согласование прошло через пуш / смс / звонок / вручную
|
||
// через менеджера» и «в колонке с именем клиента пишется просто «Согласовано» —
|
||
// непонятно, как и кем». Это единственный источник подписей канала: их берут
|
||
// таблица реестра, карточка заказа и воронка на «Аналитике».
|
||
//
|
||
// Источник правды — order_groups.delivery_date_source. Его пишут:
|
||
// manual — логист в админке (orderGroupRepository.assignDeliveryDate);
|
||
// autocall — робот, когда клиент выбрал слот в звонке (edge fn autocall-result);
|
||
// push/sms/messenger/client — клиент на странице /delivery/{hash}?src=...
|
||
// (до 29.09.2026 канал клиента НЕ записывался — такие строки разбираем по
|
||
// отметке приглашения: confirmed_at, ключ 'link').
|
||
export const CONFIRM_CHANNEL_META = {
|
||
push: { label: "Push", short: "push", tone: "accent", icon: "🔔" },
|
||
sms: { label: "SMS-ссылка", short: "SMS", tone: "ok", icon: "✉️" },
|
||
call: { label: "Звонок робота", short: "звонок", tone: "info", icon: "📞" },
|
||
manual: { label: "Логист", short: "логист", tone: "accent", icon: "🧑💼" },
|
||
messenger: { label: "Мессенджер", short: "мессенджер", tone: "info", icon: "💬" },
|
||
link: { label: "По ссылке", short: "ссылка", tone: "ok", icon: "🔗" },
|
||
};
|
||
|
||
// Порядок для легенды и воронки: от автоматических каналов к ручным.
|
||
export const CONFIRM_CHANNEL_ORDER = ["push", "sms", "call", "link", "messenger", "manual"];
|
||
|
||
/**
|
||
* Каким каналом согласовали дату. null — дата не проставлена или канал неизвестен.
|
||
* Принимает строку группы (camelCase из orderGroupRepository) или snake_case из БД.
|
||
*/
|
||
export const resolveConfirmChannel = (group = {}) => {
|
||
if (!group) return null;
|
||
// Без даты канала быть не может: иначе реестр напишет «согласовано логистом»
|
||
// там, где даты ещё нет вовсе.
|
||
const hasDate = !!(
|
||
group.deliveryDate || group.delivery_date || group.pickupDate || group.pickup_date
|
||
);
|
||
if (!hasDate) return null;
|
||
const source = String(group.deliveryDateSource ?? group.delivery_date_source ?? "").trim().toLowerCase();
|
||
if (source === "autocall") return "call";
|
||
if (source === "manual") return "manual";
|
||
if (source === "push") return "push";
|
||
if (source === "sms") return "sms";
|
||
if (source === "messenger" || source === "client") return "messenger";
|
||
// Источник пуст → старые записи (канал клиента не писался). Смотрим отметку приглашения.
|
||
const invited = group.invitationConfirmedAt ?? group.invitation_confirmed_at
|
||
?? group.deliveryInvitationConfirmedAt ?? group.delivery_invitation_confirmed_at
|
||
?? group.deliveryInvitation?.confirmedAt;
|
||
if (invited) return "link";
|
||
const opened = group.invitationOpenedAt ?? group.invitation_opened_at;
|
||
if (opened) return "link";
|
||
if (group.manualConfirmationAt ?? group.manual_confirmation_at) return "manual";
|
||
if (group.called ?? group.called_at) return "call";
|
||
return null;
|
||
};
|
||
|
||
/** Подпись канала для интерфейса; null, если канал неизвестен. */
|
||
export const confirmChannelMeta = (group = {}) => {
|
||
const key = resolveConfirmChannel(group);
|
||
return key ? { key, ...CONFIRM_CHANNEL_META[key] } : null;
|
||
};
|
||
|
||
export const filterOrderGroups = (groups, filters = {}) => {
|
||
const query = normalizeDate(filters.query).trim().toLowerCase();
|
||
const status = filters.status || "all";
|
||
const displayStatus = normalizeDate(filters.displayStatus || "all");
|
||
const deliveryStatus = normalizeDate(filters.deliveryStatus || "all");
|
||
const dateFrom = normalizeDate(filters.dateFrom);
|
||
const dateTo = normalizeDate(filters.dateTo);
|
||
const deliveryHalfDay = normalizeDate(filters.deliveryHalfDay || filters.timeSlot || "all");
|
||
const smsStatus = filters.smsStatus || "all";
|
||
const autocallStatus = filters.autocallStatus || "all";
|
||
|
||
const isWithinDateRange = (group) => {
|
||
const deliveryDate = parseGroupDate(getDeliveryDate(group));
|
||
|
||
if (!deliveryDate) {
|
||
return !dateFrom && !dateTo;
|
||
}
|
||
|
||
if (dateFrom) {
|
||
const fromDate = parseGroupDate(dateFrom);
|
||
if (fromDate && deliveryDate < fromDate) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (dateTo) {
|
||
const toDate = parseGroupDate(dateTo);
|
||
if (toDate && deliveryDate > toDate) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
const getSearchHaystack = (group) =>
|
||
(group.searchText ||
|
||
[
|
||
group.groupKey,
|
||
group.displayTitle,
|
||
group.customerName,
|
||
group.customerPhone,
|
||
group.customerDate,
|
||
Array.isArray(group.orderNumbers) ? group.orderNumbers.join(" ") : "",
|
||
group.status,
|
||
getOrderGroupStatusLabel(group.status),
|
||
group.deliveryStatus,
|
||
getOrderGroupDeliveryStatusLabel(group.deliveryStatus),
|
||
group.city,
|
||
group.customerAddress,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" "))
|
||
.toLowerCase();
|
||
|
||
return (groups || []).filter((group) => {
|
||
if (status !== "all" && group.status !== status) {
|
||
return false;
|
||
}
|
||
|
||
if (displayStatus !== "all" && getOrderGroupDisplayStatusValue(group) !== displayStatus) {
|
||
return false;
|
||
}
|
||
|
||
if (deliveryStatus !== "all") {
|
||
const groupDeliveryStatus = group.deliveryStatus || group.delivery_status || "pending_confirmation";
|
||
|
||
if (groupDeliveryStatus !== deliveryStatus) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!isWithinDateRange(group)) {
|
||
return false;
|
||
}
|
||
|
||
if (deliveryHalfDay !== "all") {
|
||
const groupDeliveryHalfDay = getOrderGroupDeliveryHalfDay(group);
|
||
|
||
if (deliveryHalfDay === "unknown") {
|
||
if (groupDeliveryHalfDay) {
|
||
return false;
|
||
}
|
||
} else if (groupDeliveryHalfDay !== HALF_DAY_LABELS[deliveryHalfDay]) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const cityFilter = (filters.city || "").trim().toLowerCase();
|
||
if (cityFilter) {
|
||
const groupCity = (group.city || "").toLowerCase();
|
||
if (!groupCity.includes(cityFilter) && cityFilter !== groupCity) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const deliveryTypeFilter = (filters.deliveryType || "").trim().toLowerCase();
|
||
if (deliveryTypeFilter && group.deliveryType !== deliveryTypeFilter) {
|
||
return false;
|
||
}
|
||
|
||
if (smsStatus !== "all") {
|
||
const status = getSmsStatus(group);
|
||
if (smsStatus === "not_sent" && status !== "not_sent") return false;
|
||
if (smsStatus === "sent" && status !== "sent") return false;
|
||
if (smsStatus === "delivered" && status !== "delivered") return false;
|
||
if (smsStatus === "error" && status !== "error") return false;
|
||
}
|
||
|
||
// Фильтр «Статус автодозвона»
|
||
if (autocallStatus !== "all") {
|
||
const acStatus = group.autocall_status || group.autocallStatus || resolveAutocallStatus([], {
|
||
optOut: group.autocall_opt_out,
|
||
exclusionReason: group.autocall_exclusion_reason,
|
||
cityDisabled: isCityCallsDisabled(group),
|
||
});
|
||
if (acStatus !== autocallStatus) return false;
|
||
}
|
||
|
||
if (!query) {
|
||
return true;
|
||
}
|
||
|
||
return getSearchHaystack(group).includes(query);
|
||
});
|
||
};
|
||
|
||
export const ORDER_GROUP_STATUS_LABELS = {
|
||
ready_for_notification: "Готово к уведомлению",
|
||
sms_sent: "SMS отправлены",
|
||
manual_work: "Нужна ручная работа",
|
||
ready_to_launch: "Готово к запуску",
|
||
first_sms_sent: "1-е приглашение отправлено",
|
||
second_sms_sent: "2-е приглашение отправлено",
|
||
draft: "Черновик",
|
||
};
|
||
|
||
export const ORDER_GROUP_DISPLAY_STATUS_OPTIONS = [
|
||
{ value: "all", label: "Все статусы" },
|
||
{ value: "delivery:pending_confirmation", label: DELIVERY_GROUP_STATUS_LABELS.pending_confirmation },
|
||
{ value: "delivery:manual_confirmation_required", label: DELIVERY_GROUP_STATUS_LABELS.manual_confirmation_required },
|
||
{ value: "delivery:agreed", label: DELIVERY_GROUP_STATUS_LABELS.agreed },
|
||
{ value: "delivery:driver_assigned", label: DELIVERY_GROUP_STATUS_LABELS.driver_assigned },
|
||
{ value: "delivery:loaded", label: DELIVERY_GROUP_STATUS_LABELS.loaded },
|
||
{ value: "delivery:delivered", label: DELIVERY_GROUP_STATUS_LABELS.delivered },
|
||
{ value: "delivery:picked_up", label: DELIVERY_GROUP_STATUS_LABELS.picked_up },
|
||
{ value: "delivery:problem", label: DELIVERY_GROUP_STATUS_LABELS.problem },
|
||
{ value: "delivery:paid_storage", label: DELIVERY_GROUP_STATUS_LABELS.paid_storage },
|
||
{ value: "delivery:cancelled", label: DELIVERY_GROUP_STATUS_LABELS.cancelled },
|
||
{ value: "status:manual_required", label: "Нужен менеджер" },
|
||
{ value: "status:ready_for_notification", label: "Готово к уведомлению" },
|
||
{ value: "status:ready_to_launch", label: "Готово к запуску" },
|
||
{ value: "status:first_sms_sent", label: NOTIFICATION_STATUS_LABELS.first_sms_sent },
|
||
{ value: "status:second_sms_sent", label: NOTIFICATION_STATUS_LABELS.second_sms_sent },
|
||
{ value: "status:send_failed", label: NOTIFICATION_STATUS_LABELS.send_failed },
|
||
{ value: "status:link_ready", label: NOTIFICATION_STATUS_LABELS.link_ready },
|
||
];
|
||
|
||
export const getOrderGroupStatusLabel = (status) =>
|
||
ORDER_GROUP_STATUS_LABELS[status] || (status ? `Неизвестно (${status})` : "Неизвестно");
|
||
|
||
export const getOrderGroupDeliveryStatusTone = (status) => {
|
||
switch (status) {
|
||
case "pending_confirmation":
|
||
return "neutral";
|
||
case "manual_confirmation_required":
|
||
return "warning";
|
||
case "agreed":
|
||
return "accent";
|
||
case "driver_assigned":
|
||
return "info";
|
||
case "loaded":
|
||
return "info";
|
||
case "on_route":
|
||
return "warning";
|
||
case "delivered":
|
||
return "accent";
|
||
case "picked_up":
|
||
return "accent";
|
||
case "paid_storage":
|
||
return "warning";
|
||
case "problem":
|
||
return "danger";
|
||
case "cancelled":
|
||
return "danger";
|
||
default:
|
||
return "neutral";
|
||
}
|
||
};
|
||
|
||
export const groupOrderGroupsByDate = (groups) => {
|
||
const buckets = (groups || []).reduce((accumulator, group) => {
|
||
const date = getDeliveryDate(group) || "Без даты";
|
||
accumulator[date] = accumulator[date] || [];
|
||
accumulator[date].push(group);
|
||
return accumulator;
|
||
}, {});
|
||
|
||
return Object.entries(buckets)
|
||
.sort(([leftDate], [rightDate]) => {
|
||
const leftTime = parseGroupDate(leftDate)?.getTime();
|
||
const rightTime = parseGroupDate(rightDate)?.getTime();
|
||
|
||
if (leftTime != null && rightTime != null && leftTime !== rightTime) {
|
||
return leftTime - rightTime;
|
||
}
|
||
|
||
return leftDate.localeCompare(rightDate);
|
||
})
|
||
.map(([date, items]) => ({
|
||
date,
|
||
items: [...items].sort((left, right) => {
|
||
const leftCount = Number(left.ordersCount || 0);
|
||
const rightCount = Number(right.ordersCount || 0);
|
||
|
||
if (leftCount !== rightCount) {
|
||
return rightCount - leftCount;
|
||
}
|
||
|
||
return (right.updatedAt || "").localeCompare(left.updatedAt || "");
|
||
}),
|
||
}));
|
||
};
|
||
|
||
const getBucketKey = (group) => {
|
||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||
|
||
if (notificationStatus === "manual_required") {
|
||
return "manual_work";
|
||
}
|
||
|
||
if (group.smsSentAt) {
|
||
return "sms_sent";
|
||
}
|
||
|
||
if ((group.readyCount || 0) > 0 && (group.notReadyCount || 0) > 0) {
|
||
return "manual_work";
|
||
}
|
||
|
||
if ((group.notReadyCount || 0) > 0) {
|
||
return "manual_work";
|
||
}
|
||
|
||
if (group.status === "ready_for_notification" || (group.readyCount || 0) >= (group.ordersCount || 0)) {
|
||
return "ready_to_launch";
|
||
}
|
||
|
||
return "manual_work";
|
||
};
|
||
|
||
export const ORDER_GROUP_BUCKET_LABELS = {
|
||
ready_to_launch: "Готовы к уведомлению",
|
||
sms_sent: "Уведомления отправлены",
|
||
manual_work: "Нужна ручная работа",
|
||
};
|
||
|
||
export const ORDER_GROUP_DELIVERY_HALF_DAY_OPTIONS = [
|
||
{ value: "all", label: "Все интервалы" },
|
||
{ value: "morning", label: HALF_DAY_LABELS.morning },
|
||
{ value: "afternoon", label: HALF_DAY_LABELS.afternoon },
|
||
{ value: "unknown", label: "Без времени" },
|
||
];
|
||
|
||
export const buildOrderGroupBuckets = (groups) => {
|
||
const buckets = {
|
||
ready_to_launch: [],
|
||
sms_sent: [],
|
||
manual_work: [],
|
||
};
|
||
|
||
for (const group of groups || []) {
|
||
const bucketKey = getBucketKey(group);
|
||
buckets[bucketKey].push(group);
|
||
}
|
||
|
||
return buckets;
|
||
};
|
||
|
||
export const getOrderGroupStatusTone = (group) => {
|
||
const deliveryStatus = group?.deliveryStatus || group?.delivery_status;
|
||
const statusCol = group?.status;
|
||
|
||
// Highlight groups with delivery problems
|
||
if (group?.hasDeliveryProblem) return "warning";
|
||
|
||
// Priority: if status column already holds a delivery-level value, use it
|
||
if (statusCol && DELIVERY_STATUS_VALUES.has(statusCol) && statusCol !== "pending_confirmation" && statusCol !== "manual_confirmation_required") {
|
||
return getOrderGroupDeliveryStatusTone(statusCol);
|
||
}
|
||
|
||
if (deliveryStatus && deliveryStatus !== "pending_confirmation") {
|
||
return getOrderGroupDeliveryStatusTone(deliveryStatus);
|
||
}
|
||
|
||
const notificationStatus = group?.notificationStatus || group?.notification_status;
|
||
const groupStatus = group?.status;
|
||
if (notificationStatus === "send_failed" || notificationStatus === "manual_required") {
|
||
return "warning";
|
||
}
|
||
if (notificationStatus === "first_sms_sent" || notificationStatus === "second_sms_sent") {
|
||
return "accent";
|
||
}
|
||
if (groupStatus === "first_sms_sent" || groupStatus === "second_sms_sent") {
|
||
return "accent";
|
||
}
|
||
|
||
if (group.smsSentAt) {
|
||
return "accent";
|
||
}
|
||
|
||
if ((group.notReadyCount || 0) > 0) {
|
||
return "warning";
|
||
}
|
||
|
||
return "neutral";
|
||
};
|