agenda Fase B1: agendaBilling.service (read-only + helpers puros)
Continua decomposicao da agenda (apos Fase A utils). Extrai pro
service os componentes read-only / pure:
- computeSeriePrice (puro)
- generateOccurrenceDates (puro)
- loadStatusChangeContext (read-only DB — assina supabase,
ownerId, tenantId, row, eventoId,
status)
- needsStatusConfirmDialog (puro — depende so do ctx)
useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext
agora e wrapper fino que injeta supabase/ownerId/tenantId do
composable scope. _needsConfirmDialog vira alias direto.
_computeSeriePrice/_generateOccurrenceDates importados direto.
Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica
read-only de status change. Mutations (applyStatusDecisions,
createPackageContract, materializeAndChargePerSession) ficam pra
Fase B2.
Risco: zero comportamental — toda chamada produz o mesmo ctx
de antes. Codigo movido sem mudancas de logica.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — agendaBilling service (Fase B1)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers e loaders relacionados a billing da agenda, extraídos de
|
||||
| useMelissaAgenda.js pra serem reusados em Rail/Clínica.
|
||||
|
|
||||
| Esta sessão (Fase B1) cobre só read-only + helpers puros:
|
||||
| - computeSeriePrice (puro)
|
||||
| - generateOccurrenceDates (puro)
|
||||
| - loadStatusChangeContext (read-only DB)
|
||||
| - needsStatusConfirmDialog (puro)
|
||||
|
|
||||
| Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract,
|
||||
| materializeAndChargePerSession.
|
||||
|
|
||||
| Convenção: funções recebem `supabase` explícito (não usa import direto)
|
||||
| pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui
|
||||
| dispara toast — caller decide.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||
|
||||
// ── Helpers puros ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calcula o valor total da série a partir dos commitmentItems.
|
||||
*
|
||||
* @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode }
|
||||
* @returns {{ n, perSessao, packagePrice }}
|
||||
*/
|
||||
export function computeSeriePrice(recorrencia) {
|
||||
const items = recorrencia?.commitmentItems || [];
|
||||
const n = recorrencia?.qtdSessoes || 1;
|
||||
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
||||
const pacoteFechado = recorrencia?.serieValorMode === 'dividir';
|
||||
return {
|
||||
n,
|
||||
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
|
||||
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência.
|
||||
* Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly
|
||||
* (interval=1 ou 2 pra quinzenal) e custom_weekdays.
|
||||
*
|
||||
* @param {object} rule { start_date, interval, weekdays, type }
|
||||
* @param {number} max
|
||||
* @param {Set<string>} exceptionDates
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) {
|
||||
const dates = [];
|
||||
const start = new Date(`${rule.start_date}T00:00:00`);
|
||||
const interval = Math.max(1, rule.interval || 1);
|
||||
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length
|
||||
? rule.weekdays.map(Number)
|
||||
: [start.getDay()];
|
||||
const isCustom = rule.type === 'custom_weekdays';
|
||||
|
||||
const cursor = new Date(start);
|
||||
let safety = 0;
|
||||
while (dates.length < max && safety < 365 * 3) {
|
||||
const iso = dateToISO(cursor);
|
||||
const dow = cursor.getDay();
|
||||
const inWeekdays = weekdays.includes(dow);
|
||||
if (inWeekdays && !exceptionDates.has(iso)) {
|
||||
dates.push(iso);
|
||||
}
|
||||
if (isCustom) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
} else if (inWeekdays) {
|
||||
cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
} else {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
safety++;
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide se o dialog de confirmação de status change deve ser exibido.
|
||||
*
|
||||
* Pure: depende só do ctx montado por loadStatusChangeContext.
|
||||
*
|
||||
* Regras:
|
||||
* - faltou/cancelado: mostra se há regra de exceção com charge_mode != 'none'
|
||||
* OU pacote saldo/upfront
|
||||
* - realizado: mostra se há pending record OU pacote saldo
|
||||
* - agendado: (reverse) mostra se há artefatos a desfazer
|
||||
*/
|
||||
export function needsStatusConfirmDialog(status, ctx) {
|
||||
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
||||
const isRealizado = status === 'realizado';
|
||||
const isAgendado = status === 'agendado';
|
||||
const hasRegraComCobranca = ctx?.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
||||
const isPacoteSaldo = ctx?.billingContract?.charging_style === 'saldo';
|
||||
const isPacoteUpfront = ctx?.billingContract?.charging_style === 'upfront';
|
||||
const hasPending = !!ctx?.pendingRecord;
|
||||
|
||||
if (isFaltouOrCancel) {
|
||||
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
|
||||
}
|
||||
if (isRealizado) {
|
||||
return hasPending || isPacoteSaldo;
|
||||
}
|
||||
if (isAgendado) {
|
||||
const r = ctx?.reverseArtifacts;
|
||||
if (!r) return false;
|
||||
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
|
||||
return hasActiveRecords || r.saldoConsumed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Loaders (read-only DB) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Carrega contexto pra decisão de status change.
|
||||
*
|
||||
* Read-only. Não dispara toast (caller decide). Tolerante a erros parciais
|
||||
* (loga warn e segue com null).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.supabase instância do client
|
||||
* @param {object} opts.row row do agenda_eventos (pode ser parcial — usa fallbacks)
|
||||
* @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas)
|
||||
* @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado'
|
||||
* @param {string} opts.ownerId auth.uid() (resolvido pelo caller)
|
||||
* @param {string} opts.tenantId activeTenantId
|
||||
*
|
||||
* @returns {Promise<{
|
||||
* regraExcecao,
|
||||
* billingContract,
|
||||
* pendingRecord,
|
||||
* existingPaidRecord,
|
||||
* reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null
|
||||
* }>}
|
||||
*/
|
||||
export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) {
|
||||
const ctx = {
|
||||
regraExcecao: null,
|
||||
billingContract: null,
|
||||
pendingRecord: null,
|
||||
existingPaidRecord: null,
|
||||
reverseArtifacts: null
|
||||
};
|
||||
|
||||
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
|
||||
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
|
||||
const excType = exceptionTypeMap[status];
|
||||
if (excType && tenantId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('exception_type', excType)
|
||||
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.regraExcecao = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] regra de exceção:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query
|
||||
// agenda_eventos.billing_contract_id (recém-materializada) → contrato
|
||||
// ativo do paciente (virtuais).
|
||||
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
|
||||
const contractId = row?.billing_contract_id ?? null;
|
||||
if (contractId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', contractId)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via id direto:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && eventoId) {
|
||||
try {
|
||||
const { data: ev } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (ev?.billing_contract_id) {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', ev.billing_contract_id)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via agenda_evento:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && patientId && tenantId) {
|
||||
try {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'active')
|
||||
.eq('type', 'package')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via patient_id:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Pending record
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
.select('*')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.pendingRecord = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] pending record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status, amount, final_amount, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.eq('status', 'paid')
|
||||
.order('paid_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.existingPaidRecord = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] existing paid record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Reverse transition (status novo='agendado'): artefatos a desfazer.
|
||||
if (status === 'agendado' && eventoId) {
|
||||
ctx.reverseArtifacts = {
|
||||
previousStatus: row?.status || null,
|
||||
activeRecords: [],
|
||||
saldoConsumed: false
|
||||
};
|
||||
try {
|
||||
const { data: evRow } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('status, billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (evRow) {
|
||||
ctx.reverseArtifacts.previousStatus = evRow.status;
|
||||
}
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status, amount, final_amount, description, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.neq('status', 'cancelled')
|
||||
.order('created_at', { ascending: false });
|
||||
ctx.reverseArtifacts.activeRecords = recs || [];
|
||||
// Heurística saldo consumido: billing_contract_id + previousStatus
|
||||
// ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha
|
||||
// do user no dialog de "devolver saldo".
|
||||
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
|
||||
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] reverse artifacts:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -52,6 +52,14 @@ import { pickDbFields } from '@/features/agenda/utils/dbFields';
|
||||
import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||
import { pickColor } from '@/features/agenda/utils/colors';
|
||||
|
||||
// ─── Service de billing (Fase B1: read-only + helpers puros) ────────────────
|
||||
import {
|
||||
computeSeriePrice as _computeSeriePrice,
|
||||
generateOccurrenceDates as _generateOccurrenceDates,
|
||||
loadStatusChangeContext,
|
||||
needsStatusConfirmDialog
|
||||
} from '@/features/agenda/services/agendaBilling.service';
|
||||
|
||||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
||||
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
||||
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
|
||||
@@ -1267,186 +1275,22 @@ function _buildHandlers(deps) {
|
||||
}
|
||||
|
||||
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
|
||||
// Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId
|
||||
// do escopo do composable. Lógica pura mora em agendaBilling.service.
|
||||
async function _loadStatusChangeContext({ row, eventoId, status }) {
|
||||
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null };
|
||||
|
||||
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
|
||||
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
|
||||
const excType = exceptionTypeMap[status];
|
||||
if (excType && clinicTenantId.value) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', clinicTenantId.value)
|
||||
.eq('exception_type', excType)
|
||||
.or(`owner_id.eq.${ownerId.value},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.regraExcecao = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro carregando regra de exceção:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Billing contract — tenta 3 caminhos:
|
||||
// (a) row.billing_contract_id direto (sessão real materializada)
|
||||
// (b) eventoId real → query agenda_eventos.billing_contract_id
|
||||
// (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente
|
||||
const patientId = row.patient_id ?? row.paciente_id ?? null;
|
||||
const contractId = row.billing_contract_id ?? null;
|
||||
if (contractId) {
|
||||
try {
|
||||
const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle();
|
||||
ctx.billingContract = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro contract via id direto:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && eventoId) {
|
||||
// Sessão real materializada — pode ter billing_contract_id no DB mesmo
|
||||
// que a row passada não tenha (caso de virtual recém-materializada).
|
||||
try {
|
||||
const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle();
|
||||
if (ev?.billing_contract_id) {
|
||||
const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro contract via agenda_evento:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && patientId && clinicTenantId.value) {
|
||||
// Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo
|
||||
// do paciente. MVP assume 1 contrato active por paciente; pega o
|
||||
// mais recente caso haja mais de um.
|
||||
try {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('tenant_id', clinicTenantId.value)
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'active')
|
||||
.eq('type', 'package')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro contract via patient_id:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Pending record (se evento já existe e tem cobrança pendente)
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
.select('*')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.pendingRecord = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro pending record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
||||
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
|
||||
// já existe ao tempo do status change. Dialog precisa saber pra:
|
||||
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
|
||||
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status, amount, final_amount, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.eq('status', 'paid')
|
||||
.order('paid_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.existingPaidRecord = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro existing paid record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Reverse transition (status novo='agendado'): carrega artefatos
|
||||
// a desfazer — current status + ALL records ativos + saldo consumido.
|
||||
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
|
||||
if (status === 'agendado' && eventoId) {
|
||||
ctx.reverseArtifacts = {
|
||||
previousStatus: row?.status || null,
|
||||
activeRecords: [],
|
||||
saldoConsumed: false
|
||||
};
|
||||
try {
|
||||
// Status atual do DB (fonte autoritativa, row pode estar stale)
|
||||
const { data: evRow } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('status, billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (evRow) {
|
||||
ctx.reverseArtifacts.previousStatus = evRow.status;
|
||||
}
|
||||
// Todos records NÃO cancelled vinculados (pending + overdue + paid)
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status, amount, final_amount, description, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.neq('status', 'cancelled')
|
||||
.order('created_at', { ascending: false });
|
||||
ctx.reverseArtifacts.activeRecords = recs || [];
|
||||
// Detecta saldo consumido: evento pertence a pacote saldo e
|
||||
// está em status que tipicamente consome (realizado, ou faltou/
|
||||
// cancelado se default_consume_on_miss=true e foi aplicado).
|
||||
// Heurística simples: se billing_contract_id está set + style=saldo
|
||||
// + status anterior ≠ 'agendado', assume consumido. Se for falso
|
||||
// positivo, user pode escolher "não devolver" no dialog.
|
||||
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
|
||||
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
|
||||
} catch (e) {
|
||||
console.warn('[Fase5] erro reverse artifacts:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
return loadStatusChangeContext({
|
||||
supabase,
|
||||
row,
|
||||
eventoId,
|
||||
status,
|
||||
ownerId: ownerId.value,
|
||||
tenantId: clinicTenantId.value
|
||||
});
|
||||
}
|
||||
|
||||
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
|
||||
// OU pacote saldo OU pacote upfront OU pending record (realizado).
|
||||
function _needsConfirmDialog(status, ctx) {
|
||||
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
|
||||
const isRealizado = status === 'realizado';
|
||||
const isAgendado = status === 'agendado';
|
||||
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
|
||||
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
|
||||
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
|
||||
const hasPending = !!ctx.pendingRecord;
|
||||
|
||||
if (isFaltouOrCancel) {
|
||||
// Mostra se há regra ou se é pacote saldo (pra perguntar consume)
|
||||
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
|
||||
}
|
||||
if (isRealizado) {
|
||||
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
|
||||
return hasPending || isPacoteSaldo;
|
||||
}
|
||||
if (isAgendado) {
|
||||
// Reverse transition: mostra se há artefatos a desfazer
|
||||
const r = ctx.reverseArtifacts;
|
||||
if (!r) return false;
|
||||
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
|
||||
return hasActiveRecords || r.saldoConsumed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
|
||||
// do agendaBilling.service. Pure — sem deps de composable state.
|
||||
const _needsConfirmDialog = needsStatusConfirmDialog;
|
||||
|
||||
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
|
||||
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
|
||||
@@ -2511,18 +2355,7 @@ function _buildOnDialogDelete(deps) {
|
||||
|
||||
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
|
||||
// Calcula valor total da série a partir dos commitmentItems.
|
||||
function _computeSeriePrice(recorrencia) {
|
||||
const items = recorrencia.commitmentItems || [];
|
||||
const n = recorrencia.qtdSessoes;
|
||||
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
||||
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
|
||||
return {
|
||||
n,
|
||||
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
|
||||
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
|
||||
};
|
||||
}
|
||||
// _computeSeriePrice extraído pra agendaBilling.service (Fase B1) — import no topo.
|
||||
|
||||
// chargeMode='package' — 2 estilos (2026-05-14):
|
||||
// - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência
|
||||
@@ -2756,37 +2589,5 @@ async function _materializeAndChargePerSession({ rule, normalized, recorrencia,
|
||||
}
|
||||
}
|
||||
|
||||
// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas
|
||||
// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1
|
||||
// ou 2 pra quinzenal) e custom_weekdays.
|
||||
function _generateOccurrenceDates(rule, max, exceptionDates) {
|
||||
const dates = [];
|
||||
const start = new Date(`${rule.start_date}T00:00:00`);
|
||||
const interval = Math.max(1, rule.interval || 1);
|
||||
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()];
|
||||
const isCustom = rule.type === 'custom_weekdays';
|
||||
|
||||
const cursor = new Date(start);
|
||||
let safety = 0;
|
||||
// Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14.
|
||||
// Para custom_weekdays, avança 1 dia e filtra weekdays.includes.
|
||||
while (dates.length < max && safety < 365 * 3) {
|
||||
const iso = _dateToISO(cursor);
|
||||
const dow = cursor.getDay();
|
||||
const inWeekdays = weekdays.includes(dow);
|
||||
if (inWeekdays && !exceptionDates.has(iso)) {
|
||||
dates.push(iso);
|
||||
}
|
||||
if (isCustom) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
} else if (inWeekdays) {
|
||||
// weekly/quinzenal: ao bater o dow, pula interval semanas
|
||||
cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
} else {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
safety++;
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
|
||||
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
|
||||
|
||||
Reference in New Issue
Block a user