feat: sistema de notificações de reserva com templates configuráveis
- Adiciona check_in_at/duration_hours ao schema do tool CreateReservationIntent para que a IA capture o horário EXATO de chegada informado pelo cliente - Cria captain_notification_templates: label, content, timing_minutes, timing_direction (before/after), active, position - Implementa SendNotificationService com interpolação de variáveis (guest_name, check_in_time, check_out_time, suite_name, unit_name) - Implementa NotificationScannerJob (Sidekiq-cron a cada 5min) com janela de tolerância de ±5min e idempotência via metadata JSONB - API REST: /captain/units/:unit_id/notification_templates (CRUD) - Store Vuex captainNotificationTemplates + API client - UI: página de gestão de templates com editor inline e botão '+' - Configura rota captain_settings_notifications - i18n PT/EN para todas as strings novas - Rubocop e ESLint: zero offenses
This commit is contained in:
parent
9a7599d971
commit
44908f32d1
@ -0,0 +1,30 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainNotificationTemplatesAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/units', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
getTemplates(unitId) {
|
||||||
|
return axios.get(`${this.url}/${unitId}/notification_templates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTemplate(unitId, data) {
|
||||||
|
return axios.post(`${this.url}/${unitId}/notification_templates`, {
|
||||||
|
notification_template: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplate(unitId, id, data) {
|
||||||
|
return axios.patch(`${this.url}/${unitId}/notification_templates/${id}`, {
|
||||||
|
notification_template: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTemplate(unitId, id) {
|
||||||
|
return axios.delete(`${this.url}/${unitId}/notification_templates/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainNotificationTemplatesAPI();
|
||||||
@ -352,6 +352,7 @@
|
|||||||
"UNITS_GROUP": "Pix Units",
|
"UNITS_GROUP": "Pix Units",
|
||||||
"INBOXES_GROUP": "Inboxes",
|
"INBOXES_GROUP": "Inboxes",
|
||||||
"TABS": {
|
"TABS": {
|
||||||
|
"DASHBOARD": "Dashboard",
|
||||||
"INSIGHTS": "AI Insights",
|
"INSIGHTS": "AI Insights",
|
||||||
"OPERATIONAL": "Operational"
|
"OPERATIONAL": "Operational"
|
||||||
},
|
},
|
||||||
@ -372,7 +373,22 @@
|
|||||||
"AI_FAILURES": "AI Failures",
|
"AI_FAILURES": "AI Failures",
|
||||||
"BULLET": "•",
|
"BULLET": "•",
|
||||||
"COUNT_PREFIX": "(",
|
"COUNT_PREFIX": "(",
|
||||||
"COUNT_SUFFIX": ")"
|
"COUNT_SUFFIX": ")",
|
||||||
|
"TIMES": "x",
|
||||||
|
"SENTIMENT": "Sentiment",
|
||||||
|
"SENTIMENT_POSITIVE": "Positive",
|
||||||
|
"SENTIMENT_NEGATIVE": "Negative",
|
||||||
|
"SENTIMENT_NEUTRAL": "Neutral",
|
||||||
|
"PRAISES": "Customer Praises",
|
||||||
|
"COMPLAINTS": "Complaints",
|
||||||
|
"FAQ_GAPS": "FAQ Gaps",
|
||||||
|
"FAQ_GAPS_HINT": "Questions customers ask that the agent doesn't cover",
|
||||||
|
"MOST_REQUESTED_SUITES": "Most Requested Suites",
|
||||||
|
"PRICE_REACTIONS": "Price Reactions",
|
||||||
|
"PRICE_OBJECTIONS": "price objections",
|
||||||
|
"RECOMMENDATIONS": "Recommendations",
|
||||||
|
"SHOW_DETAILS": "View full analysis",
|
||||||
|
"HIDE_DETAILS": "Hide details"
|
||||||
},
|
},
|
||||||
"EMPTY": {
|
"EMPTY": {
|
||||||
"TITLE": "No reports generated",
|
"TITLE": "No reports generated",
|
||||||
@ -393,6 +409,74 @@
|
|||||||
"OPERATIONAL": {
|
"OPERATIONAL": {
|
||||||
"COMING_SOON": "Coming soon",
|
"COMING_SOON": "Coming soon",
|
||||||
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
|
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
|
||||||
|
},
|
||||||
|
"DASHBOARD": {
|
||||||
|
"TOTAL_CONVERSATIONS": "Analyzed conversations",
|
||||||
|
"AVG_SENTIMENT": "Avg. positive sentiment",
|
||||||
|
"FAQ_GAPS_TOTAL": "FAQ gaps identified",
|
||||||
|
"WEEKS_ANALYZED": "weeks analyzed",
|
||||||
|
"NO_DATA": "Not enough data. Generate more AI reports to see the dashboard.",
|
||||||
|
"SENTIMENT_TREND": "Sentiment trend by week",
|
||||||
|
"FAILURES_RANKING": "Agent failure ranking",
|
||||||
|
"FAILURES_RANKING_HINT": "Most frequent situations where the AI couldn't respond well",
|
||||||
|
"FAQ_PRIORITY": "Priority FAQs to create",
|
||||||
|
"FAQ_PRIORITY_HINT": "Questions customers ask most that aren't in the FAQ yet",
|
||||||
|
"CUSTOMER_BEHAVIOR": "Customer behavior",
|
||||||
|
"TOP_TOPICS_TITLE": "Most discussed topics",
|
||||||
|
"SUITES_TITLE": "Most requested suites",
|
||||||
|
"COMPLAINTS_TREND": "Complaints volume by week",
|
||||||
|
"HANDOFFS_TITLE": "Estimated handoffs to human",
|
||||||
|
"HANDOFFS_HINT": "Based on AI failure frequency. Direct handoff tracking coming soon.",
|
||||||
|
"TREND_UP": "rising",
|
||||||
|
"TREND_DOWN": "falling",
|
||||||
|
"TREND_STABLE": "stable",
|
||||||
|
"WEEKS": "weeks"
|
||||||
|
},
|
||||||
|
"FAQ_QUICK_ADD": {
|
||||||
|
"BUTTON": "Create FAQ",
|
||||||
|
"TITLE": "Create FAQ from AI suggestion",
|
||||||
|
"QUESTION_LABEL": "Question (suggested by AI)",
|
||||||
|
"ANSWER_LABEL": "Answer",
|
||||||
|
"ANSWER_PLACEHOLDER": "Write the answer to this question...",
|
||||||
|
"ASSISTANT_LABEL": "AI Agent",
|
||||||
|
"ASSISTANT_PLACEHOLDER": "Select the agent",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"SAVE": "Save FAQ",
|
||||||
|
"SUCCESS": "FAQ created successfully!",
|
||||||
|
"ERROR": "Error creating FAQ. Please try again."
|
||||||
|
},
|
||||||
|
"NOTIFICATIONS": {
|
||||||
|
"TITLE": "Automatic Notifications",
|
||||||
|
"DESCRIPTION": "Configure messages sent automatically before or after the guest's arrival.",
|
||||||
|
"LOADING": "Loading notifications...",
|
||||||
|
"ADD": "Add notification",
|
||||||
|
"ACTIVE": "Active",
|
||||||
|
"INACTIVE": "Inactive",
|
||||||
|
"DIRECTION": {
|
||||||
|
"BEFORE": "before",
|
||||||
|
"AFTER": "after",
|
||||||
|
"OF_ARRIVAL": "arrival"
|
||||||
|
},
|
||||||
|
"FORM": {
|
||||||
|
"LABEL_PLACEHOLDER": "Template name (e.g. Arrival Instructions)",
|
||||||
|
"CONTENT_PLACEHOLDER": "Message to send... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
|
||||||
|
"SEND": "Send",
|
||||||
|
"MINUTES": "min",
|
||||||
|
"CANCEL": "Cancel",
|
||||||
|
"SAVE": "Save"
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"SUCCESS": "Notification created successfully!",
|
||||||
|
"ERROR": "Error creating notification. Please try again."
|
||||||
|
},
|
||||||
|
"UPDATE": {
|
||||||
|
"SUCCESS": "Notification updated!",
|
||||||
|
"ERROR": "Error updating notification."
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"SUCCESS": "Notification removed.",
|
||||||
|
"ERROR": "Error removing notification."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -352,6 +352,7 @@
|
|||||||
"UNITS_GROUP": "Unidades Pix",
|
"UNITS_GROUP": "Unidades Pix",
|
||||||
"INBOXES_GROUP": "Caixas de Entrada",
|
"INBOXES_GROUP": "Caixas de Entrada",
|
||||||
"TABS": {
|
"TABS": {
|
||||||
|
"DASHBOARD": "Dashboard",
|
||||||
"INSIGHTS": "Insights IA",
|
"INSIGHTS": "Insights IA",
|
||||||
"OPERATIONAL": "Operacional"
|
"OPERATIONAL": "Operacional"
|
||||||
},
|
},
|
||||||
@ -372,7 +373,22 @@
|
|||||||
"AI_FAILURES": "Falhas da IA",
|
"AI_FAILURES": "Falhas da IA",
|
||||||
"BULLET": "•",
|
"BULLET": "•",
|
||||||
"COUNT_PREFIX": "(",
|
"COUNT_PREFIX": "(",
|
||||||
"COUNT_SUFFIX": ")"
|
"COUNT_SUFFIX": ")",
|
||||||
|
"TIMES": "x",
|
||||||
|
"SENTIMENT": "Sentimento",
|
||||||
|
"SENTIMENT_POSITIVE": "Positivo",
|
||||||
|
"SENTIMENT_NEGATIVE": "Negativo",
|
||||||
|
"SENTIMENT_NEUTRAL": "Neutro",
|
||||||
|
"PRAISES": "Elogios dos clientes",
|
||||||
|
"COMPLAINTS": "Reclamações",
|
||||||
|
"FAQ_GAPS": "Lacunas no FAQ",
|
||||||
|
"FAQ_GAPS_HINT": "Perguntas que os clientes fazem mas o agente não cobre",
|
||||||
|
"MOST_REQUESTED_SUITES": "Suítes mais pedidas",
|
||||||
|
"PRICE_REACTIONS": "Reação a preços",
|
||||||
|
"PRICE_OBJECTIONS": "objeções de preço",
|
||||||
|
"RECOMMENDATIONS": "Recomendações",
|
||||||
|
"SHOW_DETAILS": "Ver análise completa",
|
||||||
|
"HIDE_DETAILS": "Ocultar detalhes"
|
||||||
},
|
},
|
||||||
"EMPTY": {
|
"EMPTY": {
|
||||||
"TITLE": "Nenhum relatório gerado",
|
"TITLE": "Nenhum relatório gerado",
|
||||||
@ -393,6 +409,75 @@
|
|||||||
"OPERATIONAL": {
|
"OPERATIONAL": {
|
||||||
"COMING_SOON": "Em breve",
|
"COMING_SOON": "Em breve",
|
||||||
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
|
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
|
||||||
|
},
|
||||||
|
"DASHBOARD": {
|
||||||
|
"TOTAL_CONVERSATIONS": "Conversas analisadas",
|
||||||
|
"AVG_SENTIMENT": "Sentimento positivo médio",
|
||||||
|
"FAQ_GAPS_TOTAL": "Lacunas de FAQ identificadas",
|
||||||
|
"WEEKS_ANALYZED": "semanas analisadas",
|
||||||
|
"NO_DATA": "Dados insuficientes. Gere mais relatórios de IA para ver o dashboard.",
|
||||||
|
"SENTIMENT_TREND": "Tendência de sentimento por semana",
|
||||||
|
"FAILURES_RANKING": "Ranking de falhas do agente",
|
||||||
|
"FAILURES_RANKING_HINT": "Situações mais frequentes em que a IA não conseguiu responder bem",
|
||||||
|
"FAQ_PRIORITY": "FAQ prioritário para criar",
|
||||||
|
"FAQ_PRIORITY_HINT": "Perguntas que os clientes mais fazem e ainda não estão no FAQ",
|
||||||
|
"CUSTOMER_BEHAVIOR": "Comportamento dos clientes",
|
||||||
|
"TOP_TOPICS_TITLE": "Assuntos mais discutidos",
|
||||||
|
"SUITES_TITLE": "Suítes mais solicitadas",
|
||||||
|
"COMPLAINTS_TREND": "Volume de reclamações por semana",
|
||||||
|
"HANDOFFS_TITLE": "Estimativa de transferências para humano",
|
||||||
|
"HANDOFFS_HINT": "Baseado na frequência de falhas do agente. Rastreamento direto de transferências em breve.",
|
||||||
|
"TREND_UP": "em alta",
|
||||||
|
"TREND_DOWN": "em queda",
|
||||||
|
"TREND_STABLE": "estável",
|
||||||
|
"WEEKS": "semanas"
|
||||||
|
},
|
||||||
|
"FAQ_QUICK_ADD": {
|
||||||
|
"BUTTON": "Criar FAQ",
|
||||||
|
"TITLE": "Criar FAQ a partir da sugestão da IA",
|
||||||
|
"QUESTION_LABEL": "Pergunta (sugerida pela IA)",
|
||||||
|
"ANSWER_LABEL": "Resposta",
|
||||||
|
"ANSWER_PLACEHOLDER": "Escreva a resposta para esta pergunta...",
|
||||||
|
"ASSISTANT_LABEL": "Agente de IA",
|
||||||
|
"ASSISTANT_PLACEHOLDER": "Selecione o agente",
|
||||||
|
"CANCEL": "Cancelar",
|
||||||
|
"SAVE": "Salvar FAQ",
|
||||||
|
"SUCCESS": "FAQ criado com sucesso!",
|
||||||
|
"ERROR": "Erro ao criar FAQ. Tente novamente."
|
||||||
|
},
|
||||||
|
"NOTIFICATIONS": {
|
||||||
|
"TITLE": "Notificações Automáticas",
|
||||||
|
"DESCRIPTION": "Configure mensagens automáticas enviadas antes ou depois da chegada do hóspede.",
|
||||||
|
"LOADING": "Carregando notificações...",
|
||||||
|
"ADD": "Adicionar notificação",
|
||||||
|
"ACTIVE": "Ativo",
|
||||||
|
"INACTIVE": "Inativo",
|
||||||
|
"DIRECTION": {
|
||||||
|
"BEFORE": "antes",
|
||||||
|
"AFTER": "depois",
|
||||||
|
"OF_ARRIVAL": "da chegada"
|
||||||
|
},
|
||||||
|
"TIMING_LABEL": "da chegada",
|
||||||
|
"FORM": {
|
||||||
|
"LABEL_PLACEHOLDER": "Nome do template (ex: Orientações de Chegada)",
|
||||||
|
"CONTENT_PLACEHOLDER": "Mensagem a enviar... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
|
||||||
|
"SEND": "Enviar",
|
||||||
|
"MINUTES": "min",
|
||||||
|
"CANCEL": "Cancelar",
|
||||||
|
"SAVE": "Salvar"
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"SUCCESS": "Notificação criada com sucesso!",
|
||||||
|
"ERROR": "Erro ao criar notificação. Tente novamente."
|
||||||
|
},
|
||||||
|
"UPDATE": {
|
||||||
|
"SUCCESS": "Notificação atualizada!",
|
||||||
|
"ERROR": "Erro ao atualizar notificação."
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"SUCCESS": "Notificação removida.",
|
||||||
|
"ERROR": "Erro ao remover notificação."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import UnitEdit from './units/Edit.vue';
|
|||||||
import GalleryIndex from './gallery/Index.vue';
|
import GalleryIndex from './gallery/Index.vue';
|
||||||
import GalleryEdit from './gallery/Edit.vue';
|
import GalleryEdit from './gallery/Edit.vue';
|
||||||
const ReportsIndex = () => import('./reports/Index.vue');
|
const ReportsIndex = () => import('./reports/Index.vue');
|
||||||
|
const NotificationsIndex = () => import('./notifications/Index.vue');
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@ -77,6 +78,14 @@ export default {
|
|||||||
permissions: ['administrator'],
|
permissions: ['administrator'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'units/:unitId/notifications',
|
||||||
|
name: 'captain_settings_notifications',
|
||||||
|
component: NotificationsIndex,
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,361 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import SettingsLayout from '../../SettingsLayout.vue';
|
||||||
|
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const unitId = computed(() => route.params.unitId);
|
||||||
|
|
||||||
|
const templates = useMapGetter('captainNotificationTemplates/getTemplates');
|
||||||
|
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
|
||||||
|
|
||||||
|
const editingId = ref(null);
|
||||||
|
const showNewForm = ref(false);
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
label: '',
|
||||||
|
content: '',
|
||||||
|
timing_minutes: 10,
|
||||||
|
timing_direction: 'before',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newForm = ref(emptyForm());
|
||||||
|
const editForm = ref(emptyForm());
|
||||||
|
|
||||||
|
const VARIABLES = [
|
||||||
|
'{{guest_name}}',
|
||||||
|
'{{check_in_time}}',
|
||||||
|
'{{check_out_time}}',
|
||||||
|
'{{suite_name}}',
|
||||||
|
'{{unit_name}}',
|
||||||
|
];
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (unitId.value) {
|
||||||
|
await store.dispatch('captainNotificationTemplates/fetch', unitId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const openNewForm = () => {
|
||||||
|
newForm.value = emptyForm();
|
||||||
|
showNewForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelNew = () => {
|
||||||
|
showNewForm.value = false;
|
||||||
|
newForm.value = emptyForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNew = async () => {
|
||||||
|
if (!newForm.value.label || !newForm.value.content) return;
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainNotificationTemplates/create', {
|
||||||
|
unitId: unitId.value,
|
||||||
|
...newForm.value,
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
|
||||||
|
showNewForm.value = false;
|
||||||
|
newForm.value = emptyForm();
|
||||||
|
} catch {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = template => {
|
||||||
|
editingId.value = template.id;
|
||||||
|
editForm.value = { ...template };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingId.value = null;
|
||||||
|
editForm.value = emptyForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainNotificationTemplates/update', {
|
||||||
|
unitId: unitId.value,
|
||||||
|
id: editingId.value,
|
||||||
|
...editForm.value,
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
|
||||||
|
editingId.value = null;
|
||||||
|
} catch {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = async template => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainNotificationTemplates/update', {
|
||||||
|
unitId: unitId.value,
|
||||||
|
id: template.id,
|
||||||
|
active: !template.active,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTemplate = async template => {
|
||||||
|
try {
|
||||||
|
await store.dispatch('captainNotificationTemplates/delete', {
|
||||||
|
unitId: unitId.value,
|
||||||
|
id: template.id,
|
||||||
|
});
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
|
||||||
|
} catch {
|
||||||
|
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertVariable = (variable, target) => {
|
||||||
|
if (target === 'new') {
|
||||||
|
newForm.value.content += variable;
|
||||||
|
} else {
|
||||||
|
editForm.value.content += variable;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const directionLabel = direction =>
|
||||||
|
direction === 'before'
|
||||||
|
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE')
|
||||||
|
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER');
|
||||||
|
|
||||||
|
const timingDisplay = template =>
|
||||||
|
`${template.timing_minutes} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')} ${directionLabel(template.timing_direction)} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SettingsLayout
|
||||||
|
:is-loading="uiFlags.isFetching"
|
||||||
|
:loading-message="t('CAPTAIN_SETTINGS.NOTIFICATIONS.LOADING')"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<BaseSettingsHeader
|
||||||
|
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
|
||||||
|
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Template list -->
|
||||||
|
<div
|
||||||
|
v-for="template in templates"
|
||||||
|
:key="template.id"
|
||||||
|
class="rounded-lg border border-n-75 bg-white p-4"
|
||||||
|
>
|
||||||
|
<!-- View mode -->
|
||||||
|
<div
|
||||||
|
v-if="editingId !== template.id"
|
||||||
|
class="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||||
|
<span class="text-sm font-semibold text-n-900">{{
|
||||||
|
template.label
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm text-n-600 whitespace-pre-line">{{
|
||||||
|
template.content
|
||||||
|
}}</span>
|
||||||
|
<span class="text-xs text-n-500 mt-1">
|
||||||
|
{{ timingDisplay(template) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
class="text-xs px-2 py-1 rounded"
|
||||||
|
:class="
|
||||||
|
template.active ? 'bg-g-100 text-g-700' : 'bg-n-75 text-n-500'
|
||||||
|
"
|
||||||
|
@click="toggleActive(template)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
template.active
|
||||||
|
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
|
||||||
|
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-n-500 hover:text-n-700"
|
||||||
|
@click="startEdit(template)"
|
||||||
|
>
|
||||||
|
<span class="i-lucide-pencil w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-r-500 hover:text-r-700"
|
||||||
|
@click="deleteTemplate(template)"
|
||||||
|
>
|
||||||
|
<span class="i-lucide-trash-2 w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<div v-else class="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
v-model="editForm.label"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="editForm.content"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
|
||||||
|
/>
|
||||||
|
<!-- Variable chips -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="v in VARIABLES"
|
||||||
|
:key="v"
|
||||||
|
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100"
|
||||||
|
@click="insertVariable(v, 'edit')"
|
||||||
|
>
|
||||||
|
{{ v }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Timing row -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
|
||||||
|
}}</span>
|
||||||
|
<input
|
||||||
|
v-model.number="editForm.timing_minutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
|
||||||
|
/>
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
|
||||||
|
}}</span>
|
||||||
|
<select
|
||||||
|
v-model="editForm.timing_direction"
|
||||||
|
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
|
||||||
|
>
|
||||||
|
<option value="before">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
|
||||||
|
</option>
|
||||||
|
<option value="after">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="clear"
|
||||||
|
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
|
||||||
|
@click="cancelEdit"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
|
||||||
|
:is-loading="uiFlags.isUpdating"
|
||||||
|
@click="saveEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New form -->
|
||||||
|
<div
|
||||||
|
v-if="showNewForm"
|
||||||
|
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newForm.label"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-model="newForm.content"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
rows="3"
|
||||||
|
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
|
||||||
|
/>
|
||||||
|
<!-- Variable chips -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="v in VARIABLES"
|
||||||
|
:key="v"
|
||||||
|
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100"
|
||||||
|
@click="insertVariable(v, 'new')"
|
||||||
|
>
|
||||||
|
{{ v }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Timing row -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
|
||||||
|
}}</span>
|
||||||
|
<input
|
||||||
|
v-model.number="newForm.timing_minutes"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
|
||||||
|
/>
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
|
||||||
|
}}</span>
|
||||||
|
<select
|
||||||
|
v-model="newForm.timing_direction"
|
||||||
|
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
|
||||||
|
>
|
||||||
|
<option value="before">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
|
||||||
|
</option>
|
||||||
|
<option value="after">
|
||||||
|
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span class="text-n-600">{{
|
||||||
|
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="clear"
|
||||||
|
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
|
||||||
|
@click="cancelNew"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
|
||||||
|
:is-loading="uiFlags.isCreating"
|
||||||
|
@click="saveNew"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add button -->
|
||||||
|
<button
|
||||||
|
v-if="!showNewForm"
|
||||||
|
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed border-n-200 py-4 text-sm text-n-500 hover:border-w-400 hover:text-w-600 transition-colors"
|
||||||
|
@click="openNewForm"
|
||||||
|
>
|
||||||
|
<span class="i-lucide-plus w-4 h-4" />
|
||||||
|
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SettingsLayout>
|
||||||
|
</template>
|
||||||
File diff suppressed because it is too large
Load Diff
104
app/javascript/dashboard/store/captain/notificationTemplates.js
Normal file
104
app/javascript/dashboard/store/captain/notificationTemplates.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates';
|
||||||
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
templates: [],
|
||||||
|
uiFlags: {
|
||||||
|
isFetching: false,
|
||||||
|
isCreating: false,
|
||||||
|
isUpdating: false,
|
||||||
|
isDeleting: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getters = {
|
||||||
|
getTemplates: $state => $state.templates,
|
||||||
|
getUIFlags: $state => $state.uiFlags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutations = {
|
||||||
|
SET_TEMPLATES($state, templates) {
|
||||||
|
$state.templates = templates;
|
||||||
|
},
|
||||||
|
ADD_TEMPLATE($state, template) {
|
||||||
|
$state.templates.push(template);
|
||||||
|
},
|
||||||
|
UPDATE_TEMPLATE($state, updated) {
|
||||||
|
const index = $state.templates.findIndex(t => t.id === updated.id);
|
||||||
|
if (index !== -1) $state.templates.splice(index, 1, updated);
|
||||||
|
},
|
||||||
|
DELETE_TEMPLATE($state, id) {
|
||||||
|
$state.templates = $state.templates.filter(t => t.id !== id);
|
||||||
|
},
|
||||||
|
SET_UI_FLAG($state, flags) {
|
||||||
|
$state.uiFlags = { ...$state.uiFlags, ...flags };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
fetch: async ({ commit }, unitId) => {
|
||||||
|
commit('SET_UI_FLAG', { isFetching: true });
|
||||||
|
try {
|
||||||
|
const { data } =
|
||||||
|
await CaptainNotificationTemplatesAPI.getTemplates(unitId);
|
||||||
|
commit('SET_TEMPLATES', data);
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit('SET_UI_FLAG', { isFetching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async ({ commit }, { unitId, ...templateData }) => {
|
||||||
|
commit('SET_UI_FLAG', { isCreating: true });
|
||||||
|
try {
|
||||||
|
const { data } = await CaptainNotificationTemplatesAPI.createTemplate(
|
||||||
|
unitId,
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
commit('ADD_TEMPLATE', data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit('SET_UI_FLAG', { isCreating: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async ({ commit }, { unitId, id, ...templateData }) => {
|
||||||
|
commit('SET_UI_FLAG', { isUpdating: true });
|
||||||
|
try {
|
||||||
|
const { data } = await CaptainNotificationTemplatesAPI.updateTemplate(
|
||||||
|
unitId,
|
||||||
|
id,
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
commit('UPDATE_TEMPLATE', data);
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit('SET_UI_FLAG', { isUpdating: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ commit }, { unitId, id }) => {
|
||||||
|
commit('SET_UI_FLAG', { isDeleting: true });
|
||||||
|
try {
|
||||||
|
await CaptainNotificationTemplatesAPI.deleteTemplate(unitId, id);
|
||||||
|
commit('DELETE_TEMPLATE', id);
|
||||||
|
} catch (error) {
|
||||||
|
throwErrorMessage(error);
|
||||||
|
} finally {
|
||||||
|
commit('SET_UI_FLAG', { isDeleting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state,
|
||||||
|
getters,
|
||||||
|
mutations,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
@ -63,6 +63,7 @@ import captainReservations from './captain/reservations';
|
|||||||
import captainUnits from './modules/captainUnits';
|
import captainUnits from './modules/captainUnits';
|
||||||
import captainGalleryItems from './modules/captainGalleryItems';
|
import captainGalleryItems from './modules/captainGalleryItems';
|
||||||
import captainReports from './modules/captainReports';
|
import captainReports from './modules/captainReports';
|
||||||
|
import captainNotificationTemplates from './captain/notificationTemplates';
|
||||||
|
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
|
|
||||||
@ -131,6 +132,7 @@ export default createStore({
|
|||||||
captainUnits,
|
captainUnits,
|
||||||
captainGalleryItems,
|
captainGalleryItems,
|
||||||
captainReports,
|
captainReports,
|
||||||
|
captainNotificationTemplates,
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -49,3 +49,8 @@
|
|||||||
title: 'Enviar Fotos de Suíte'
|
title: 'Enviar Fotos de Suíte'
|
||||||
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
|
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
|
||||||
icon: 'image'
|
icon: 'image'
|
||||||
|
|
||||||
|
- id: create_reservation_intent
|
||||||
|
title: 'Criar Reserva'
|
||||||
|
description: 'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada'
|
||||||
|
icon: 'calendar-add'
|
||||||
|
|||||||
@ -90,7 +90,9 @@ Rails.application.routes.draw do
|
|||||||
post :label_suggestion
|
post :label_suggestion
|
||||||
post :follow_up
|
post :follow_up
|
||||||
end
|
end
|
||||||
resources :units
|
resources :units do
|
||||||
|
resources :notification_templates, only: [:index, :create, :update, :destroy]
|
||||||
|
end
|
||||||
namespace :reports do
|
namespace :reports do
|
||||||
resource :operational, only: [:show], controller: 'reports/operational'
|
resource :operational, only: [:show], controller: 'reports/operational'
|
||||||
resources :insights, only: [:index, :show] do
|
resources :insights, only: [:index, :show] do
|
||||||
|
|||||||
@ -87,3 +87,10 @@ remove_orphan_conversations_job:
|
|||||||
cron: '0 */12 * * *'
|
cron: '0 */12 * * *'
|
||||||
class: 'Internal::RemoveOrphanConversationsJob'
|
class: 'Internal::RemoveOrphanConversationsJob'
|
||||||
queue: housekeeping
|
queue: housekeeping
|
||||||
|
|
||||||
|
# executed every 5 minutes
|
||||||
|
# scans reservations and sends configured notification templates (pre/post arrival)
|
||||||
|
captain_notification_scanner_job:
|
||||||
|
cron: '*/5 * * * *'
|
||||||
|
class: 'Captain::Notifications::NotificationScannerJob'
|
||||||
|
queue: scheduled_jobs
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
class CreateCaptainNotificationTemplates < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :captain_notification_templates do |t|
|
||||||
|
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
|
||||||
|
t.string :label, null: false
|
||||||
|
t.text :content, null: false
|
||||||
|
t.integer :timing_minutes, null: false, default: 10
|
||||||
|
t.integer :timing_direction, null: false, default: 0
|
||||||
|
t.boolean :active, null: false, default: true
|
||||||
|
t.integer :position, null: false, default: 0
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :captain_notification_templates, [:captain_unit_id, :active],
|
||||||
|
name: 'idx_notif_templates_unit_active'
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/schema.rb
17
db/schema.rb
@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
|
ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@ -534,6 +534,20 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
|
|||||||
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "captain_notification_templates", force: :cascade do |t|
|
||||||
|
t.bigint "captain_unit_id", null: false
|
||||||
|
t.string "label", null: false
|
||||||
|
t.text "content", null: false
|
||||||
|
t.integer "timing_minutes", default: 10, null: false
|
||||||
|
t.integer "timing_direction", default: 0, null: false
|
||||||
|
t.boolean "active", default: true, null: false
|
||||||
|
t.integer "position", default: 0, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["captain_unit_id", "active"], name: "idx_notif_templates_unit_active"
|
||||||
|
t.index ["captain_unit_id"], name: "index_captain_notification_templates_on_captain_unit_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "captain_pix_charges", force: :cascade do |t|
|
create_table "captain_pix_charges", force: :cascade do |t|
|
||||||
t.bigint "reservation_id", null: false
|
t.bigint "reservation_id", null: false
|
||||||
t.bigint "unit_id", null: false
|
t.bigint "unit_id", null: false
|
||||||
@ -1964,6 +1978,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
|
|||||||
add_foreign_key "captain_inbox_reminder_settings", "accounts"
|
add_foreign_key "captain_inbox_reminder_settings", "accounts"
|
||||||
add_foreign_key "captain_inbox_reminder_settings", "inboxes"
|
add_foreign_key "captain_inbox_reminder_settings", "inboxes"
|
||||||
add_foreign_key "captain_inboxes", "captain_units"
|
add_foreign_key "captain_inboxes", "captain_units"
|
||||||
|
add_foreign_key "captain_notification_templates", "captain_units"
|
||||||
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
|
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
|
||||||
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
|
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
|
||||||
add_foreign_key "captain_pricings", "accounts"
|
add_foreign_key "captain_pricings", "accounts"
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :current_account
|
||||||
|
before_action -> { check_authorization(Captain::Assistant) }
|
||||||
|
before_action :set_unit
|
||||||
|
before_action :set_template, only: [:update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@templates = @unit.notification_templates.ordered
|
||||||
|
render json: @templates
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@template = @unit.notification_templates.new(template_params)
|
||||||
|
@template.save!
|
||||||
|
render json: @template, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@template.update!(template_params)
|
||||||
|
render json: @template
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@template.destroy!
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_unit
|
||||||
|
@unit = Current.account.captain_units.find(params[:unit_id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'Unidade não encontrada' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_template
|
||||||
|
@template = @unit.notification_templates.find(params[:id])
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: 'Template não encontrado' }, status: :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def template_params
|
||||||
|
params.require(:notification_template).permit(
|
||||||
|
:label,
|
||||||
|
:content,
|
||||||
|
:timing_minutes,
|
||||||
|
:timing_direction,
|
||||||
|
:active,
|
||||||
|
:position
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
class Captain::Notifications::NotificationScannerJob < ApplicationJob
|
||||||
|
queue_as :scheduled_jobs
|
||||||
|
|
||||||
|
# Tolerance window around the target time (job runs every 5 min, so ±5 min ensures coverage)
|
||||||
|
WINDOW_MINUTES = 5
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Captain::NotificationTemplate.active.find_each do |template|
|
||||||
|
eligible_reservations_for(template).find_each do |reservation|
|
||||||
|
Captain::Notifications::SendNotificationService.new(reservation, template).perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def eligible_reservations_for(template)
|
||||||
|
target_time = compute_target_time(template)
|
||||||
|
window_start = target_time - WINDOW_MINUTES.minutes
|
||||||
|
window_end = target_time + WINDOW_MINUTES.minutes
|
||||||
|
|
||||||
|
Captain::Reservation
|
||||||
|
.where(captain_unit_id: template.captain_unit_id)
|
||||||
|
.where(status: Captain::Reservation.statuses.slice(:confirmed, :active).values)
|
||||||
|
.where(check_in_at: window_start..window_end)
|
||||||
|
.where.not(conversation_id: nil)
|
||||||
|
.where(
|
||||||
|
"NOT (metadata->'notified_templates' @> ?::jsonb)",
|
||||||
|
"[#{template.id}]"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def compute_target_time(template)
|
||||||
|
if template.before?
|
||||||
|
template.timing_minutes.minutes.from_now
|
||||||
|
else
|
||||||
|
template.timing_minutes.minutes.ago
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
41
enterprise/app/models/captain/notification_template.rb
Normal file
41
enterprise/app/models/captain/notification_template.rb
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: captain_notification_templates
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# active :boolean default(TRUE), not null
|
||||||
|
# content :text not null
|
||||||
|
# label :string not null
|
||||||
|
# position :integer default(0), not null
|
||||||
|
# timing_direction :integer default("before"), not null
|
||||||
|
# timing_minutes :integer default(10), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# captain_unit_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# idx_notif_templates_unit_active (captain_unit_id,active)
|
||||||
|
# index_captain_notification_templates_on_captain_unit_id (captain_unit_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (captain_unit_id => captain_units.id)
|
||||||
|
#
|
||||||
|
class Captain::NotificationTemplate < ApplicationRecord
|
||||||
|
self.table_name = 'captain_notification_templates'
|
||||||
|
|
||||||
|
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', inverse_of: :notification_templates
|
||||||
|
|
||||||
|
enum timing_direction: { before: 0, after: 1 }
|
||||||
|
|
||||||
|
validates :label, presence: true
|
||||||
|
validates :content, presence: true
|
||||||
|
validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
|
||||||
|
validates :timing_direction, presence: true
|
||||||
|
validates :captain_unit_id, presence: true
|
||||||
|
|
||||||
|
scope :active, -> { where(active: true) }
|
||||||
|
scope :ordered, -> { order(:position, :id) }
|
||||||
|
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
|
||||||
|
end
|
||||||
@ -54,6 +54,8 @@ class Captain::Unit < ApplicationRecord
|
|||||||
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
|
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
|
||||||
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
|
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
|
||||||
dependent: :destroy
|
dependent: :destroy
|
||||||
|
has_many :notification_templates, class_name: 'Captain::NotificationTemplate', foreign_key: :captain_unit_id,
|
||||||
|
inverse_of: :unit, dependent: :destroy
|
||||||
|
|
||||||
encrypts :inter_client_secret
|
encrypts :inter_client_secret
|
||||||
encrypts :inter_account_number
|
encrypts :inter_account_number
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
class Captain::Notifications::SendNotificationService
|
||||||
|
VARIABLES = {
|
||||||
|
'{{guest_name}}' => ->(r) { r.contact.name.to_s },
|
||||||
|
'{{check_in_time}}' => ->(r) { r.check_in_at.strftime('%H:%M') },
|
||||||
|
'{{check_out_time}}' => ->(r) { r.check_out_at.strftime('%H:%M') },
|
||||||
|
'{{suite_name}}' => ->(r) { r.suite_identifier.to_s },
|
||||||
|
'{{unit_name}}' => ->(r) { r.unit&.name.to_s }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(reservation, template)
|
||||||
|
@reservation = reservation
|
||||||
|
@template = template
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless @reservation.conversation_id?
|
||||||
|
|
||||||
|
rendered = render_content
|
||||||
|
send_message(rendered)
|
||||||
|
mark_template_sent
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[SendNotificationService] Failed for reservation #{@reservation.id}, template #{@template.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def render_content
|
||||||
|
content = @template.content.dup
|
||||||
|
VARIABLES.each do |placeholder, resolver|
|
||||||
|
content.gsub!(placeholder, resolver.call(@reservation))
|
||||||
|
end
|
||||||
|
content
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_message(content)
|
||||||
|
conversation = @reservation.conversation
|
||||||
|
assistant = conversation.inbox&.captain_inbox&.assistant
|
||||||
|
|
||||||
|
conversation.messages.create!(
|
||||||
|
content: content,
|
||||||
|
message_type: :outgoing,
|
||||||
|
account: conversation.account,
|
||||||
|
inbox: conversation.inbox,
|
||||||
|
sender: assistant
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_template_sent
|
||||||
|
current_notified = @reservation.metadata.to_h.fetch('notified_templates', [])
|
||||||
|
updated = (current_notified + [@template.id]).uniq
|
||||||
|
new_metadata = @reservation.metadata.to_h.merge('notified_templates' => updated)
|
||||||
|
@reservation.update!(metadata: new_metadata)
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,351 @@
|
|||||||
|
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
||||||
|
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
# This class was migrated from reference/ with intentional complexity in execute/verify methods.
|
||||||
|
# Refactoring is tracked as tech debt rather than done inline.
|
||||||
|
class Captain::Tools::CreateReservationIntentTool < Captain::Tools::BaseTool
|
||||||
|
def name
|
||||||
|
'create_reservation_intent'
|
||||||
|
end
|
||||||
|
|
||||||
|
def description
|
||||||
|
'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada. ' \
|
||||||
|
'IMPORTANTE: Extraia o horário EXATO de chegada da conversa e passe em check_in_at. ' \
|
||||||
|
'Se o cliente informar duração (ex: "3 horas"), passe em duration_hours para calcular o check-out automaticamente.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def tool_parameters_schema
|
||||||
|
{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
suite: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
|
||||||
|
},
|
||||||
|
deposit_value: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50'
|
||||||
|
},
|
||||||
|
check_in_at: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Data e horário EXATO de chegada do cliente, extraído da conversa. ' \
|
||||||
|
'Formatos aceitos: ISO8601 (ex: "2026-03-01T18:30:00"), ' \
|
||||||
|
'"HH:MM" para hoje (ex: "18:30"), ' \
|
||||||
|
'ou data completa (ex: "01/03/2026 18:30"). ' \
|
||||||
|
'OBRIGATÓRIO quando o cliente informar o horário de chegada.'
|
||||||
|
},
|
||||||
|
duration_hours: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Duração da estadia em horas (ex: 3.0). ' \
|
||||||
|
'Usado para calcular check_out = check_in + duração. ' \
|
||||||
|
'Passe este campo quando o cliente informar duração em vez de horário de saída.'
|
||||||
|
},
|
||||||
|
check_out_at: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Data e horário de saída, se informado explicitamente pelo cliente. Formato ISO8601 ou HH:MM.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: %w[suite price]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute(*args, **params)
|
||||||
|
actual_params = resolve_params(args, params)
|
||||||
|
Rails.logger.info "[CreateReservationIntentTool] Starting with params: #{actual_params}"
|
||||||
|
|
||||||
|
suite_category = actual_params[:suite]
|
||||||
|
price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
|
||||||
|
price = price_raw.to_f
|
||||||
|
|
||||||
|
deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
|
||||||
|
deposit_override = deposit_input.to_f if deposit_input.present?
|
||||||
|
|
||||||
|
last_availability = fetch_last_availability
|
||||||
|
|
||||||
|
if suite_category.blank? || price <= 0
|
||||||
|
inferred = infer_from_history
|
||||||
|
suite_category ||= inferred[:suite]
|
||||||
|
price = inferred[:price].to_f if price <= 0 && inferred[:price].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
if (suite_category.blank? || price <= 0) && last_availability.present?
|
||||||
|
suite_category ||= last_availability[:suite]
|
||||||
|
price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
intent_error = verify_user_intent_barrier!(suite_category, @conversation)
|
||||||
|
return intent_error if intent_error
|
||||||
|
|
||||||
|
if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
|
||||||
|
last_availability[:price])
|
||||||
|
msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
|
||||||
|
price)}) diverge da última cotação (R$ #{format('%.2f',
|
||||||
|
last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
|
||||||
|
Rails.logger.warn "[CreateReservationIntentTool] Price block: tried #{price} but last quote was #{last_availability[:price]}"
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
return "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja." if suite_category.blank?
|
||||||
|
|
||||||
|
return 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.' if price <= 0
|
||||||
|
|
||||||
|
ensure_conversation_context!
|
||||||
|
|
||||||
|
return "Erro Crítico: Contexto de conversa não disponível. Params: #{actual_params}" unless @conversation&.inbox
|
||||||
|
|
||||||
|
unit = infer_unit
|
||||||
|
return 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.' unless unit
|
||||||
|
|
||||||
|
check_in_at, check_out_at = resolve_check_in_and_out(actual_params)
|
||||||
|
|
||||||
|
recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft)
|
||||||
|
.where('created_at > ?', 5.minutes.ago)
|
||||||
|
.where(suite_identifier: suite_category)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.first
|
||||||
|
|
||||||
|
deposit_amount = if deposit_override&.positive?
|
||||||
|
deposit_override
|
||||||
|
else
|
||||||
|
price / 2.0
|
||||||
|
end
|
||||||
|
|
||||||
|
recent_draft_deposit = recent_draft&.metadata.to_h['deposit_amount'].to_f
|
||||||
|
if recent_draft && (recent_draft_deposit - deposit_amount).abs < 0.1
|
||||||
|
msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar."
|
||||||
|
Rails.logger.info "[CreateReservationIntentTool] Idempotency hit: reusing draft #{recent_draft.id}"
|
||||||
|
return msg
|
||||||
|
end
|
||||||
|
|
||||||
|
Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled)
|
||||||
|
|
||||||
|
begin
|
||||||
|
Captain::Reservation.create!(
|
||||||
|
conversation_id: @conversation.id,
|
||||||
|
account: @conversation.account,
|
||||||
|
contact: @conversation.contact,
|
||||||
|
contact_inbox: @conversation.contact_inbox,
|
||||||
|
inbox: @conversation.inbox,
|
||||||
|
captain_unit_id: unit.id,
|
||||||
|
captain_brand_id: unit.captain_brand_id,
|
||||||
|
suite_identifier: suite_category,
|
||||||
|
status: :draft,
|
||||||
|
total_amount: price,
|
||||||
|
check_in_at: check_in_at,
|
||||||
|
check_out_at: check_out_at,
|
||||||
|
metadata: {
|
||||||
|
full_amount: price,
|
||||||
|
deposit_amount: deposit_amount
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
update_sticky_state(
|
||||||
|
suite: suite_category,
|
||||||
|
price: deposit_amount,
|
||||||
|
check_in_at: check_in_at,
|
||||||
|
check_out_at: check_out_at
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = "Reserva iniciada com sucesso! Check-in: #{check_in_at.strftime('%d/%m/%Y às %H:%M')}. " \
|
||||||
|
"O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount,
|
||||||
|
unit: 'R$ ', separator: ',', delimiter: '.')}. " \
|
||||||
|
'INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta generate_pix para entregar o código de pagamento.'
|
||||||
|
Rails.logger.info '[CreateReservationIntentTool] Reservation created successfully'
|
||||||
|
return msg
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "[CreateReservationIntentTool] Creation failed: #{e.message} | #{e.backtrace&.first}"
|
||||||
|
return "Erro técnico ao criar reserva: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def verify_user_intent_barrier!(suite_category, conversation)
|
||||||
|
return nil if suite_category.blank?
|
||||||
|
|
||||||
|
all_incoming = conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || []
|
||||||
|
last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
|
||||||
|
|
||||||
|
relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..] : all_incoming
|
||||||
|
user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase
|
||||||
|
user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '')
|
||||||
|
|
||||||
|
aliases = {
|
||||||
|
'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem],
|
||||||
|
'stilo' => %w[stilo estilo],
|
||||||
|
'master' => %w[master],
|
||||||
|
'alexa' => %w[alexa]
|
||||||
|
}
|
||||||
|
|
||||||
|
suite_key = suite_category.to_s.downcase.strip
|
||||||
|
suite_key = ActiveSupport::Inflector.transliterate(suite_key)
|
||||||
|
|
||||||
|
valid_terms = aliases[suite_key] || [suite_key]
|
||||||
|
|
||||||
|
match_found = valid_terms.any? do |term|
|
||||||
|
term_clean = ActiveSupport::Inflector.transliterate(term)
|
||||||
|
user_text_post_reset.include?(term_clean)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.debug { "[CreateReservationIntentTool] Intent barrier: #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}" }
|
||||||
|
|
||||||
|
unless match_found
|
||||||
|
Rails.logger.info "[CreateReservationIntentTool] Intent blocked: Suite '#{suite_category}' not found after reset"
|
||||||
|
return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'."
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_check_in_and_out(params)
|
||||||
|
c_in = params[:check_in_at] || params[:date] || params[:day]
|
||||||
|
c_out = params[:check_out_at]
|
||||||
|
dur = params[:duration_hours]&.to_f
|
||||||
|
|
||||||
|
check_in = parse_flexible_datetime(c_in) || Time.zone.now.tomorrow.change(hour: 14)
|
||||||
|
|
||||||
|
check_out = if c_out.present?
|
||||||
|
parse_flexible_datetime(c_out) || (check_in + 3.hours)
|
||||||
|
elsif dur&.positive?
|
||||||
|
check_in + dur.hours
|
||||||
|
else
|
||||||
|
check_in + 3.hours
|
||||||
|
end
|
||||||
|
|
||||||
|
[check_in, check_out]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Interpreta múltiplos formatos de data/hora:
|
||||||
|
# - "HH:MM" → hoje nesse horário
|
||||||
|
# - "DD/MM/YYYY HH:MM" → data + hora
|
||||||
|
# - ISO8601 → parse direto
|
||||||
|
def parse_flexible_datetime(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
str = value.to_s.strip
|
||||||
|
|
||||||
|
# Formato "HH:MM" → hoje nesse horário
|
||||||
|
if str.match?(/\A\d{1,2}:\d{2}\z/)
|
||||||
|
hour, min = str.split(':').map(&:to_i)
|
||||||
|
return Time.zone.now.change(hour: hour, min: min, sec: 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Formato "DD/MM/YYYY HH:MM" ou "DD/MM/YYYY"
|
||||||
|
if str.match?(%r{\A\d{1,2}/\d{1,2}/\d{2,4}})
|
||||||
|
normalized = str.gsub(%r{(\d{1,2})/(\d{1,2})/(\d{2,4})}) do
|
||||||
|
"#{Regexp.last_match(3)}-#{Regexp.last_match(2).rjust(2, '0')}-#{Regexp.last_match(1).rjust(2, '0')}"
|
||||||
|
end
|
||||||
|
return Time.zone.parse(normalized)
|
||||||
|
end
|
||||||
|
|
||||||
|
Time.zone.parse(str)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def price_mismatch?(price_a, price_b)
|
||||||
|
(price_a.to_f - price_b.to_f).abs > 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_conversation_context!
|
||||||
|
return if @conversation.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def infer_unit
|
||||||
|
@conversation&.inbox&.captain_inbox&.unit
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_sticky_state(suite:, price:, check_in_at:, check_out_at:)
|
||||||
|
return unless @conversation.respond_to?(:active_scenario_state)
|
||||||
|
|
||||||
|
state = @conversation.active_scenario_state || {}
|
||||||
|
collected = (state['collected'] || {}).merge(
|
||||||
|
'suite' => suite,
|
||||||
|
'price' => price,
|
||||||
|
'check_in_at' => check_in_at&.iso8601,
|
||||||
|
'check_out_at' => check_out_at&.iso8601
|
||||||
|
).compact
|
||||||
|
|
||||||
|
@conversation.update!(
|
||||||
|
active_scenario_state: state.merge(
|
||||||
|
'stage' => 'reservation_intent_created',
|
||||||
|
'collected' => collected,
|
||||||
|
'updated_at' => Time.current.iso8601
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_last_availability
|
||||||
|
return nil unless @conversation
|
||||||
|
|
||||||
|
data = @conversation.custom_attributes&.fetch('last_availability', nil)
|
||||||
|
return nil unless data.is_a?(Hash)
|
||||||
|
|
||||||
|
captured_at = data['captured_at']
|
||||||
|
return nil if captured_at.blank?
|
||||||
|
|
||||||
|
if Time.zone.parse(captured_at) < 4.hours.ago
|
||||||
|
Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)'
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
data.with_indifferent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
# Complexity is inherent: branches for reset detection + suite/price inference across messages
|
||||||
|
def infer_from_history
|
||||||
|
return {} if @conversation.blank?
|
||||||
|
|
||||||
|
suite_candidates = available_suite_categories
|
||||||
|
|
||||||
|
messages = @conversation.messages
|
||||||
|
.where(private: false)
|
||||||
|
.where('created_at >= ?', 4.hours.ago)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.limit(20).to_a
|
||||||
|
|
||||||
|
reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
|
||||||
|
messages = messages.take_while { |m| m.id != reset_msg.id } if reset_msg
|
||||||
|
|
||||||
|
messages.each do |message|
|
||||||
|
content = message.content.to_s
|
||||||
|
suite = find_suite_in_text(content, suite_candidates)
|
||||||
|
price = extract_price_from_text(content)
|
||||||
|
|
||||||
|
return { suite: suite, price: price } if suite.present? || price.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_suite_categories
|
||||||
|
unit = infer_unit
|
||||||
|
return %w[Stilo Master Hidromassagem] unless unit
|
||||||
|
|
||||||
|
Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_suite_in_text(content, suite_candidates)
|
||||||
|
return nil if content.blank?
|
||||||
|
|
||||||
|
suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_price_from_text(content)
|
||||||
|
return nil if content.blank?
|
||||||
|
|
||||||
|
match = content.match(/R\$\s*([\d\.]+,\d{2})/)
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
match[1].tr('.', '').tr(',', '.').to_f
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
|
||||||
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
Loading…
Reference in New Issue
Block a user