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",
|
||||
"INBOXES_GROUP": "Inboxes",
|
||||
"TABS": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"INSIGHTS": "AI Insights",
|
||||
"OPERATIONAL": "Operational"
|
||||
},
|
||||
@ -372,7 +373,22 @@
|
||||
"AI_FAILURES": "AI Failures",
|
||||
"BULLET": "•",
|
||||
"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": {
|
||||
"TITLE": "No reports generated",
|
||||
@ -393,6 +409,74 @@
|
||||
"OPERATIONAL": {
|
||||
"COMING_SOON": "Coming 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",
|
||||
"INBOXES_GROUP": "Caixas de Entrada",
|
||||
"TABS": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"INSIGHTS": "Insights IA",
|
||||
"OPERATIONAL": "Operacional"
|
||||
},
|
||||
@ -372,7 +373,22 @@
|
||||
"AI_FAILURES": "Falhas da IA",
|
||||
"BULLET": "•",
|
||||
"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": {
|
||||
"TITLE": "Nenhum relatório gerado",
|
||||
@ -393,6 +409,75 @@
|
||||
"OPERATIONAL": {
|
||||
"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."
|
||||
},
|
||||
"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 GalleryEdit from './gallery/Edit.vue';
|
||||
const ReportsIndex = () => import('./reports/Index.vue');
|
||||
const NotificationsIndex = () => import('./notifications/Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@ -77,6 +78,14 @@ export default {
|
||||
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 captainGalleryItems from './modules/captainGalleryItems';
|
||||
import captainReports from './modules/captainReports';
|
||||
import captainNotificationTemplates from './captain/notificationTemplates';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
@ -131,6 +132,7 @@ export default createStore({
|
||||
captainUnits,
|
||||
captainGalleryItems,
|
||||
captainReports,
|
||||
captainNotificationTemplates,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@ -49,3 +49,8 @@
|
||||
title: 'Enviar Fotos de Suíte'
|
||||
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
|
||||
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 :follow_up
|
||||
end
|
||||
resources :units
|
||||
resources :units do
|
||||
resources :notification_templates, only: [:index, :create, :update, :destroy]
|
||||
end
|
||||
namespace :reports do
|
||||
resource :operational, only: [:show], controller: 'reports/operational'
|
||||
resources :insights, only: [:index, :show] do
|
||||
|
||||
@ -87,3 +87,10 @@ remove_orphan_conversations_job:
|
||||
cron: '0 */12 * * *'
|
||||
class: 'Internal::RemoveOrphanConversationsJob'
|
||||
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.
|
||||
|
||||
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
|
||||
enable_extension "pg_stat_statements"
|
||||
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"
|
||||
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|
|
||||
t.bigint "reservation_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", "inboxes"
|
||||
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_units", column: "unit_id"
|
||||
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 :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
|
||||
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_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