agenda: C7 OK + Fase 6 lock-edit ativada em Melissa + cross-week payment propagation
Cenário 7 (Pacote UPFRONT — Ana Souza Ferreira 4×R$ 200 = R$ 800)
- Testado e passou. User criou Ana, pagou os R$ 800 em dinheiro pelo
Financeiro. Borda verde + popover "Pago R$ 800" funcionando.
Fase 6 (lock-edit cobrada) ativada em Melissa
- Removido guard `if (!props.occurrenceMode) return;` em
loadOccFinancialRecord (useAgendaEventLifecycle.js:217+). Agora ele
carrega em ambos modos (Rail/Clínica E Melissa)
- loadOccFinancialRecord SINTETIZA record paid/pending pra siblings de
contrato upfront ativo — assim TODAS as ocorrências da série mostram
"Cobrança paga R$ 800 do pacote" no AgendaEventDialog
- AgendaEventDialog card Sessão/Honorários (flow Melissa) ganhou lock
template: Tag em vez de Select billingType quando occFinancialRecord
existe; Message com cadeado "Cobrança de R$ X já emitida"
- AgendaEventoFinanceiroPanel só renderiza dentro do lock quando record
é REAL (não sintetizado) — evita "Gerar cobrança" indevido em sibling
- paymentSummary do Resumo lateral unificado pra usar occFinancialRecord
(em vez do sessionPaymentRecord paralelo de antes)
Cross-week propagation de pacote upfront
- BUG: ao navegar pra semana só com virtuais (sem reais), bulk-load
caía no else `_rulePaymentMap.value = {}` — virtuais perdiam estado
paid herdado
- FIX em useMelissaAgenda._reloadRange:
* Maps (payment/amount/rule) inicializados SEMPRE no início
* Propagação roda independente de realIds.length (depende só de
ruleIdsInView.size>0, considera reais E virtuais com recurrence_id)
* Query cross-week: pra cada rule em view, busca QUALQUER evento
sibling em qualquer semana + seus records pra determinar estado do
contrato. Encontra o record do pacote mesmo em outra semana
- Saldo NÃO propaga (filter: charging_style='upfront' || NULL); cada
sessão de saldo gera cobrança individual ao realizar
- Memória durável: memory/project_cross_week_propagation.md
Visualização de virtuais cobertas
- MelissaEventoPanel.showPaymentRow: virtuais só escondem quando state
='none'. Com paid/pending herdado, exibem linha colorida
- MelissaAgenda fcEvents: isPaidSession e badge $ pendente removeram
exigência de !is_occurrence. Virtuais herdadas via propagação mostram
borda verde / badge amber
Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" quando paymentVariant
='none' && !is_occurrence. Click → gerarCobrancaManual direto, fecha
popover pra impedir double-click. Tooltip: "Gerar fatura agora"
- Wire em MelissaLayout via novo emit gerar-cobranca + handler
onGerarCobrancaQuick
Info de pacote no popover
- Header agora mostra "Sessão · Pacote · N sessões" (computed
seriesLabel lê de _raw do rule)
Botão "Excluir série inteira"
- Novo emit delete-series em MelissaEventoPanel + botão ao lado de
"Excluir sessão" quando evento tem recurrence_id
- Handler onDeleteSeries em MelissaLayout: hard delete em 3 etapas
(financial_records pendentes → agenda_eventos materializados →
recurrence_rules CASCADE leva exceptions). Bloqueia se algum record
paid (estorno via Financeiro primeiro)
cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception.type=
'cancel_session' (era visível com status cancelado; doc dizia que
some). patient_missed/therapist_canceled/holiday_block permanecem
como histórico
recurrence_exceptions cancel idempotente
- MelissaLayout onDeleteEvento usa upsert com onConflict pra exception
cancel — não quebra mais com unique violation em re-cancel
billing_contract_id na 1ª materializada
- _createPackageContract agora .select() o contrato após insert e seta
billing_contract_id no insert da 1ª agenda_eventos materializada
onVerLancamentos cobre virtual de upfront
- Antes virtual sempre toast "Sem lançamentos". Agora busca records via
siblings da série pra encontrar o do pacote. Saldo/sem pacote continua
com toast
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+92
-21
@@ -1,39 +1,112 @@
|
||||
# HANDOFF — 2026-05-19 (C1-C6 ✅, próximo C7 — pacote upfront)
|
||||
# HANDOFF — 2026-05-19 noite (C1-C7 ✅, próximo C8 — pacote saldo)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
|
||||
|
||||
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
|
||||
> testes manuais dos 13 cenários do doc viva
|
||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C6 ✅**.
|
||||
> Próximo passo: **Cenário 7** (Donald Winnicott · pacote UPFRONT · 4 ×
|
||||
> R$ 200 = cobrança única de R$ 800).
|
||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C7 ✅**.
|
||||
> Próximo: **Cenário 8** (Carl Jung · pacote SALDO · 4 × R$ 40 — modelo
|
||||
> Cliniko: contrato sem cobrança imediata, cada sessão gera record ao
|
||||
> ser realizada).
|
||||
|
||||
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 da manhã. Migration
|
||||
> nova (20260519000001) já rodada no DB local. Pronto pra próximo bloco.
|
||||
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 noite. Fase 6
|
||||
> (lock-edit cobrada) ativada em Melissa também. Lock + popover atalho
|
||||
> "Gerar fatura" + propagação cross-week de pacote upfront tudo
|
||||
> funcionando.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 7 (Pacote UPFRONT)
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 8 (Pacote SALDO)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| Paciente | **Donald Winnicott** |
|
||||
| Paciente | **Carl Jung** |
|
||||
| Frequência | **Semanal · 4 ocorrências** |
|
||||
| Serviço | qualquer (ex: Sessão particular R$ 200) |
|
||||
| Cobrança ao salvar | **Gerar cobrança** + estilo **"Pacote único (upfront)"** |
|
||||
| Total esperado | **R$ 800** (4 × 200) |
|
||||
| Serviço | Sessão (R$ 40 cada) |
|
||||
| Cobrança ao salvar | **Pacote único** + estilo **"Saldo (Cliniko)"** |
|
||||
| Total contrato | **R$ 160** (4 × 40) |
|
||||
|
||||
**Esperado:**
|
||||
- 1 row em `recurrence_rules` + **1 row em `agenda_eventos`** (1ª materializada)
|
||||
- 1 row em `billing_contracts` (type=package, charging_style=upfront, total_sessions=4, package_price=800)
|
||||
- **1 row em `financial_records`** com amount=800, status=pending (cobrança ÚNICA do pacote, vencimento na 1ª sessão)
|
||||
- Agenda: 1ª com badge $ (R$ 800 a cobrar) + 3 virtuais limpas (cobertas pelo pacote)
|
||||
- 1 row em `recurrence_rules`
|
||||
- **0 rows em `agenda_eventos`** materializadas inicialmente (saldo NÃO materializa 1ª)
|
||||
- 1 row em `billing_contracts` (type=package, charging_style=**saldo**, total_sessions=4, package_price=160)
|
||||
- **0 rows em `financial_records`** (sem cobrança imediata — modelo Cliniko)
|
||||
- Agenda: 4 ocorrências virtuais **TODAS LIMPAS** (sem badge $, sem barra verde) — saldo intencionalmente não propaga estado, cada sessão gera cobrança individual quando vira realidade
|
||||
|
||||
Após C7: C8 (saldo) → C9 (per_session) → C10-C13 (status change + edit cobrada).
|
||||
Diferença chave vs upfront:
|
||||
- Upfront: 1 cobrança única (paga ou pendente) cobre todas as 4 sessões
|
||||
- Saldo: contrato sem cobrança; cada sessão materializada GERA sua própria cobrança ao virar realidade (status realizado/faltou via flow C10-C12)
|
||||
|
||||
Após C8: C9 (per_session) → C10-C13 (status change + edit cobrada).
|
||||
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
|
||||
|
||||
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
|
||||
Testado e validado. Usuária criou Ana, R$ 200/sessão × 4 = R$ 800, marcou como pago em dinheiro pelo Financeiro. Visualização correta em mês AND em semana navegando pelas 4 semanas.
|
||||
|
||||
### Fase 6 (lock-edit cobrada) ativada em Melissa
|
||||
Antes: `loadOccFinancialRecord` tinha guard `if (!props.occurrenceMode) return;` — só carregava em Rail/Clínica (edição de ocorrência). Em Melissa, `sessionPaymentRecord` paralelo alimentava só o Resumo lateral, sem trigger de lock.
|
||||
|
||||
Agora unificado: `occFinancialRecord` carrega em ambos modos:
|
||||
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
|
||||
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
|
||||
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
|
||||
- Edição de serviços/preço bloqueada
|
||||
|
||||
### Propagação cross-week de pacote upfront pago/pendente
|
||||
**Bug descoberto durante C7:** ao navegar pra semanas futuras (onde só virtual da Ana 2/3/4 aparecia, sem real event paid na view), o `_rulePaymentMap` era zerado pelo else branch do bulk-load → virtuais perdiam estado paid.
|
||||
|
||||
Fix em `useMelissaAgenda.js _reloadRange`:
|
||||
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
|
||||
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
|
||||
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
|
||||
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
|
||||
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
|
||||
|
||||
### Atalho "Gerar fatura" no popover
|
||||
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (`paymentVariant === 'none' && !is_occurrence`)
|
||||
- Click → `gerarCobrancaManual` direto, fecha popover pra impedir double-click
|
||||
- Tooltip: "Gerar fatura agora"
|
||||
|
||||
### Info de pacote no popover
|
||||
- Header agora mostra `Sessão · Pacote · N sessões` (computed `seriesLabel` lê de `_raw` do rule)
|
||||
|
||||
### Botão "Excluir série inteira"
|
||||
- Novo emit `delete-series` em `MelissaEventoPanel` + botão ao lado de "Excluir sessão" quando evento tem `recurrence_id`
|
||||
- Handler `onDeleteSeries` em MelissaLayout faz hard delete: `financial_records` pendentes → `agenda_eventos` materializados → `recurrence_rules` (CASCADE leva exceptions + rule_services)
|
||||
- Bloqueia se algum record tem `status='paid'` (estornar primeiro)
|
||||
|
||||
### RPC `create_financial_record_for_session` ignora cancelled
|
||||
**Migration 20260519000001:** idempotência da RPC passou a filtrar `AND status != 'cancelled'` além de `deleted_at IS NULL`. Antes: cancelar cobrança sem querer → todo "Gerar fatura" subsequente retornava o cancelado em vez de criar nova. Toast verde mentindo.
|
||||
|
||||
Memória durável em `memory/project_rpc_idempotency_cancelled.md`.
|
||||
|
||||
### `cancel_session` exception some da agenda
|
||||
- `useRecurrence.expandRules` agora pula ocorrência com `exception.type === 'cancel_session'` (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha)
|
||||
- `patient_missed` / `therapist_canceled` / `holiday_block` permanecem visíveis como histórico
|
||||
|
||||
### `recurrence_exceptions` cancel idempotente
|
||||
- Cancel de ocorrência (virtual e materializada) usa `upsert` com `onConflict: 'recurrence_id,original_date'` — não quebra mais com unique violation quando há exception zumbi de tentativa anterior.
|
||||
|
||||
### Visualização paid/pending de upfront em virtuais
|
||||
- `MelissaEventoPanel.showPaymentRow` antes excluía virtuais incondicionalmente. Agora só esconde quando `paymentState === 'none'` (saldo/sem pacote continua limpo; upfront propagado mostra).
|
||||
- `MelissaAgenda.fcEvents`: removida exigência de `!is_occurrence` no `isPaidSession` e no badge $ pendente. Virtuais herdadas via propagação mostram borda verde/badge amber.
|
||||
|
||||
### `onVerLancamentos` cobre virtual de upfront
|
||||
- Antes: virtual sempre toast "Sem lançamentos". Agora: busca records via siblings da série pra encontrar o do pacote. Saldo/sem pacote continua com toast.
|
||||
|
||||
### Confirmação 3 decisões UX (não codar)
|
||||
Antes de C7, user perguntou e concordou:
|
||||
1. Editar serviço já lançado e pago → **NÃO** (cobrança fiscal imutável)
|
||||
2. Alternar Particular/Convênio/Gratuito em série com cobrança ativa → **NÃO** (mesma razão)
|
||||
3. "Gerar fatura" extra em sessão coberta por contrato upfront → **NÃO** (duplicaria cobrança)
|
||||
Tudo isso o lock-edit (Fase 6 ativada acima) cobre.
|
||||
|
||||
---
|
||||
|
||||
## 📦 O que foi feito em 18/05
|
||||
|
||||
### Cenário 4 (Joyce · "Já recebi") ✅
|
||||
@@ -150,15 +223,13 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
|
||||
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||
| **7** | **Pacote UPFRONT (Donald Winnicott 4 × R$ 200)** | 🔴 **PRÓXIMO** |
|
||||
| 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
|
||||
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
|
||||
| 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
|
||||
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
||||
| **8** | **Pacote saldo (Carl Jung 4 × R$ 40)** | 🔴 **PRÓXIMO** |
|
||||
| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ |
|
||||
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||
| 11 | Status change pacote saldo | ⏳ |
|
||||
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||
| 13 | Edit cobrada | ⏳ |
|
||||
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,65 @@ Chronological, append-only record of everything that's happened in this wiki.
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-19 23:00] session | C7 OK + Fase 6 lock em Melissa + cross-week propagation
|
||||
Touched: project_cross_week_propagation (nova)
|
||||
Detalhes: rodada longa cobrindo C7 (pacote upfront, Ana Souza Ferreira 4xR$200=R$800),
|
||||
ativacao do lock-edit (Fase 6) em Melissa e correcao de bug cross-week
|
||||
na propagacao de estado de pagamento.
|
||||
|
||||
CENARIO 7 (Pacote UPFRONT):
|
||||
- User criou Ana, pagou os R$800 em dinheiro pelo Financeiro
|
||||
- Borda verde + popover "Pago R$800" no card materializado funcionou
|
||||
- 4 pills no dialog da serie, todas com "Cobranca paga R$800 do pacote"
|
||||
ate as virtuais (via synthesized record em loadOccFinancialRecord)
|
||||
|
||||
LOCK-EDIT (Fase 6) ATIVADO EM MELISSA:
|
||||
- Antes: loadOccFinancialRecord tinha guard occurrenceMode, so ativo
|
||||
em Rail/Clinica. Em Melissa havia sessionPaymentRecord paralelo so
|
||||
pro Resumo lateral (sem trigger de lock).
|
||||
- Agora unificado: removido guard, occFinancialRecord carrega em ambos
|
||||
modos. Card Sessao/Honorarios ganha Tag em vez de Select billingType
|
||||
quando ha cobranca. Body mostra Message lock + cadeado.
|
||||
AgendaEventoFinanceiroPanel so renderiza se record real (nao
|
||||
sintetizado).
|
||||
- Antes do codar, alinhei 3 perguntas de UX com user:
|
||||
1) Editar servico pago? NAO (cobranca fiscal imutavel)
|
||||
2) Alternar Particular/Convenio/Gratuito em serie cobrada? NAO
|
||||
3) Gerar fatura individual em upfront? NAO (duplicaria cobranca)
|
||||
|
||||
CROSS-WEEK PROPAGATION (descoberto durante C7):
|
||||
- Bug: virtuais isoladas em semanas futuras nao mostravam paid.
|
||||
- Root cause: bulk-load tinha "if (realIds.length) { ... propagacao ... }
|
||||
else { _rulePaymentMap.value = {} }". Quando user navegava pra semana
|
||||
so com virtuais (sem reais), else zerava o ruleMap.
|
||||
- Fix: maps inicializados SEMPRE, propagacao roda sempre, atribuicao
|
||||
final tambem fora do if. Propagacao tambem ficou cross-week — pega
|
||||
ruleIdsInView de TODOS eventos da view (real+virtual com recurrence_id),
|
||||
busca records de QUALQUER evento da rule (em qualquer semana). Memoria
|
||||
durador em project_cross_week_propagation.md.
|
||||
|
||||
OUTROS FIXES NA RODADA:
|
||||
- Atalho "Gerar fatura" no popover (pill amber ao lado de "A cobrar R$ X").
|
||||
- Info de pacote no header popover ("Sessao · Pacote · N sessoes").
|
||||
- Botao "Excluir serie inteira" no popover (hard delete, bloqueia se
|
||||
algum record paid).
|
||||
- Migration 20260519000001: RPC create_financial_record_for_session
|
||||
ignora cancelled na idempotencia. Memoria em
|
||||
project_rpc_idempotency_cancelled.md.
|
||||
- cancel_session exception some da agenda (era visivel com status
|
||||
cancelado, doc dizia que sumia).
|
||||
- recurrence_exceptions cancel agora upsert idempotente (nao quebra
|
||||
com unique violation em re-cancel).
|
||||
- onVerLancamentos busca records via siblings da serie pra virtuais de
|
||||
upfront.
|
||||
- Visualizacao: virtuais herdadas via propagacao agora mostram borda
|
||||
verde + linha verde no popover (showPaymentRow e isPaidSession
|
||||
relaxados pra paymentState !== 'none').
|
||||
|
||||
PROXIMO: Cenario 8 (Carl Jung pacote SALDO 4x R$40 = R$160).
|
||||
Esperado: 0 records iniciais, 4 virtuais limpas (saldo NAO propaga —
|
||||
cada sessao gera cobranca quando vira realidade).
|
||||
|
||||
## [2026-05-19 14:00] session | C5+C6 OK + atalho gerar fatura + RPC idempotencia fix
|
||||
Touched: project_rpc_idempotency_cancelled (nova)
|
||||
Detalhes: sessao longa cobrindo C5 e C6 ate green checkmark, com varios
|
||||
|
||||
@@ -965,12 +965,15 @@ const occBillingStatusSeverity = computed(() => {
|
||||
// da cobrança (Pago / Pendente / Atrasada / Sem cobrança). Espelha
|
||||
// os 3 canais visuais da agenda (verde pago / amber pendente /
|
||||
// neutro). Sources:
|
||||
// - sessionPaymentRecord (1 query em useAgendaEventLifecycle)
|
||||
// - occFinancialRecord (lifecycle, agora carrega em ambos modos —
|
||||
// guard de occurrenceMode foi removido em 2026-05-19 pra ativar
|
||||
// lock-edit em Melissa tambem). Cobre record direto OU sintetizado
|
||||
// a partir de contrato upfront ativo.
|
||||
// - eventRow.price (fallback pra "Sem cobrança · R$ X" quando
|
||||
// ainda nao ha record)
|
||||
const paymentSummary = computed(() => {
|
||||
if (!isSessionEvent.value) return null;
|
||||
const rec = sessionPaymentRecord.value;
|
||||
const rec = occFinancialRecord.value;
|
||||
const fmtDate = (d) => {
|
||||
if (!d) return '';
|
||||
try {
|
||||
@@ -1770,16 +1773,22 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
<div class="field-card__body">
|
||||
<!-- LOCKED: cobranca ja emitida -->
|
||||
<!-- LOCKED: cobranca ja emitida.
|
||||
AgendaEventoFinanceiroPanel só renderiza quando
|
||||
o record é REAL (não sintetizado de contrato
|
||||
upfront em sibling). Sintetizado: a sessão não
|
||||
tem record próprio, então o panel exibiria
|
||||
"Gerar cobrança" indevidamente. O lock Message
|
||||
já comunica o estado. -->
|
||||
<template v-if="occFinancialRecord">
|
||||
<Message severity="info" :closable="false" class="m-2">
|
||||
<div class="text-sm">
|
||||
<i class="pi pi-lock mr-1" />
|
||||
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> já emitida.
|
||||
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
|
||||
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
|
||||
</div>
|
||||
</Message>
|
||||
<AgendaEventoFinanceiroPanel :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
|
||||
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
|
||||
</template>
|
||||
|
||||
<!-- LOADING -->
|
||||
@@ -2204,7 +2213,13 @@ onBeforeUnmount(() => {
|
||||
<!-- Mobile: label visível; Desktop: label some e Select
|
||||
ocupa o espaço. Mesmo padrão do Paciente e Data/Horário. -->
|
||||
<span class="md:hidden">Sessão / Honorários</span>
|
||||
<!-- Lock-edit (Fase 6, ativada em Melissa 2026-05-19):
|
||||
quando há cobrança paga/pendente OU contrato upfront
|
||||
ativo, mostra Tag em vez do Select pra impedir
|
||||
troca de billingType. -->
|
||||
<Tag v-if="occFinancialRecord" :value="occBillingStatusLabel" :severity="occBillingStatusSeverity" class="ml-auto" />
|
||||
<Select
|
||||
v-else-if="!occFinancialLoading"
|
||||
v-model="billingType"
|
||||
:options="billingTypeOptions"
|
||||
optionLabel="label"
|
||||
@@ -2213,7 +2228,20 @@ onBeforeUnmount(() => {
|
||||
class="aed-pay-mod-select ml-auto md:ml-0"
|
||||
/>
|
||||
</div>
|
||||
<Transition name="aed-pay-expand">
|
||||
<!-- LOCKED: cobrança emitida (real OU sintetizada via
|
||||
contrato upfront pago/pendente). Trava edição de
|
||||
tipo/serviços/preço. Ajustes só via Financeiro. -->
|
||||
<template v-if="occFinancialRecord">
|
||||
<Message severity="info" :closable="false" class="m-2">
|
||||
<div class="text-sm">
|
||||
<i class="pi pi-lock mr-1" />
|
||||
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
|
||||
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
|
||||
</div>
|
||||
</Message>
|
||||
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
|
||||
</template>
|
||||
<Transition v-else-if="!occFinancialLoading" name="aed-pay-expand">
|
||||
<div class="field-card__body aed-pay-body">
|
||||
<!-- Gratuito: aviso informativo, sem ações -->
|
||||
<div v-if="billingType === 'gratuito'" class="aed-pay-gratuito">
|
||||
@@ -2273,10 +2301,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Cobrança da sessão (financial_records ja criado) —
|
||||
só em edição. Movido pra dentro do Sessao/Honorarios
|
||||
em 2026-05-11 (antes ficava num side-card separado). -->
|
||||
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadSessionPaymentRecord" />
|
||||
<!-- UNLOCKED: panel pra gerar cobrança inline (atalho do
|
||||
popover é o caminho principal, mas mantido aqui pra
|
||||
paridade com flow anterior). Só aparece sem record. -->
|
||||
<AgendaEventoFinanceiroPanel
|
||||
v-if="!occFinancialRecord && !occFinancialLoading && isEdit && eventRow?.id"
|
||||
:evento="eventRow"
|
||||
class="m-3"
|
||||
@cobranca-atualizada="loadOccFinancialRecord"
|
||||
/>
|
||||
|
||||
<!-- Botão "Ver lançamentos" (2026-05-14): abre dialog
|
||||
listando todos os financial_records vinculados à
|
||||
|
||||
@@ -208,13 +208,26 @@ export function useAgendaEventLifecycle({
|
||||
}
|
||||
|
||||
// ── occurrence financial record loader ────────────────────
|
||||
// Tenta 3 caminhos pra encontrar um record relevante:
|
||||
// 1) Record direto por agenda_evento_id (materializada com cobrança própria)
|
||||
// 2) Se a sessão pertence a contrato upfront PAGO, sintetiza um record
|
||||
// 'paid' com o package_price → assim TODAS as ocorrências da série
|
||||
// mostram "Cobrança paga" no dialog (cobertas pelo pacote pago).
|
||||
// 3) Senão, null → card unlocked.
|
||||
async function loadOccFinancialRecord() {
|
||||
occFinancialRecord.value = null;
|
||||
if (!props.occurrenceMode) return;
|
||||
// Guard de occurrenceMode REMOVIDO em 2026-05-19 — necessario pra
|
||||
// que o lock de "edit cobrada" (Fase 6) tambem ative em Melissa,
|
||||
// nao so em Rail/Clinica. Padrao SimplePractice: cobranca emitida
|
||||
// eh imutavel pelo dialog; ajustes via estorno no Financeiro.
|
||||
const evId = props.eventRow?.id;
|
||||
if (!evId) return;
|
||||
const ruleId = props.eventRow?.recurrence_id;
|
||||
const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
|
||||
occFinancialLoading.value = true;
|
||||
try {
|
||||
// 1) Record direto (materializada que tem agenda_evento_id real)
|
||||
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
|
||||
if (evId && !isVirtualId) {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
@@ -224,7 +237,60 @@ export function useAgendaEventLifecycle({
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
occFinancialRecord.value = data ?? null;
|
||||
if (data) {
|
||||
occFinancialRecord.value = data;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2) Sintetiza record 'paid' quando há contrato upfront pago.
|
||||
// A 1ª materializada tem o record real; siblings (virtuais ou
|
||||
// materializadas sem cobrança individual) herdam status do
|
||||
// contrato pra UI mostrar "Cobrança paga" coerentemente.
|
||||
if (ruleId && patientId) {
|
||||
const { data: contracts } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('id, package_price, charging_style, status')
|
||||
.eq('patient_id', patientId)
|
||||
.eq('type', 'package')
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false });
|
||||
const upfront = (contracts || []).find((c) => c.charging_style === 'upfront');
|
||||
if (upfront) {
|
||||
// Confere se há record PAGO ligado a qualquer evento do
|
||||
// mesmo recurrence_id (ou seja, contrato foi quitado).
|
||||
const { data: siblingEvents } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
const ids = (siblingEvents || []).map((e) => e.id);
|
||||
if (ids.length) {
|
||||
// Pega o record mais recente NAO cancelado (paid OU
|
||||
// pending OU overdue). Pacote upfront tem 1 record
|
||||
// unico cobrindo toda a serie — qualquer status dele
|
||||
// trava as siblings (cobranca ja emitida, imutavel).
|
||||
const { data: anyRec } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.in('agenda_evento_id', ids)
|
||||
.in('status', ['paid', 'pending', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (anyRec) {
|
||||
// Sintetiza usando o package_price (não o per-session
|
||||
// que pode ter mudado depois de editar).
|
||||
occFinancialRecord.value = {
|
||||
...anyRec,
|
||||
amount: upfront.package_price,
|
||||
final_amount: upfront.package_price,
|
||||
_synthesized: true
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
occFinancialRecord.value = null;
|
||||
} catch (e) {
|
||||
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
|
||||
occFinancialRecord.value = null;
|
||||
|
||||
@@ -395,13 +395,13 @@ const fcEvents = computed(() => {
|
||||
const pStatus = ev.paciente_status;
|
||||
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
|
||||
// Sessão paga → barra esquerda verde (override do border-left que o
|
||||
// FC pinta com a cor do commitment). Espelha as mesmas condições do
|
||||
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
|
||||
// paymentState === 'paid'.
|
||||
// FC pinta com a cor do commitment). Aplica a virtuais TAMBÉM quando
|
||||
// herdam 'paid' de pacote upfront pago via propagação no bulk-load
|
||||
// (sem essa propagação, virtuais ficam paymentState='none' e o
|
||||
// check abaixo já as exclui).
|
||||
const isPaidSession =
|
||||
String(ev.tipo || '').toLowerCase() === 'sessao' &&
|
||||
(ev.patient_id || ev.paciente_id) &&
|
||||
!ev.is_occurrence &&
|
||||
ev.paymentState === 'paid';
|
||||
const cls = [];
|
||||
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
|
||||
@@ -650,7 +650,16 @@ const fcOptions = computed(() => ({
|
||||
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
|
||||
// pelo contrato, não por record-por-sessão).
|
||||
let payBadgeHtml = '';
|
||||
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
|
||||
// Badge $ amber pendente: aparece pra sessão com paciente quando há
|
||||
// cobrança pendente (paymentState='pending'/'overdue') OU quando é
|
||||
// REAL sem cobrança ainda ('none'). Virtuais com 'none' (saldo,
|
||||
// sem pacote, ou virtuais limpas) ficam SEM badge — só virtuais
|
||||
// herdando 'pending' de pacote upfront mostram o badge.
|
||||
const wantBadge = isSessao && ext.patient_id && ext.paymentState !== 'paid' && (
|
||||
ext.paymentState === 'pending' || ext.paymentState === 'overdue' ||
|
||||
(!ext.is_occurrence && (ext.paymentState === 'none' || !ext.paymentState))
|
||||
);
|
||||
if (wantBadge) {
|
||||
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,11 @@ const isSessaoComPaciente = computed(
|
||||
// — não polui séries com pacote upfront.
|
||||
const showPaymentRow = computed(() => {
|
||||
if (!isSessaoComPaciente.value) return false;
|
||||
if (ev.value.is_occurrence) return false;
|
||||
// Virtuais sem estado herdado de contrato ficam limpas (paymentState='none').
|
||||
// Quando herdam 'paid' ou 'pending' de pacote upfront via propagação no
|
||||
// bulk-load, exibem normalmente. Pacote saldo continua limpo (siblings
|
||||
// ficam 'none' propositadamente — modelo Cliniko).
|
||||
if (ev.value.is_occurrence && (!ev.value.paymentState || ev.value.paymentState === 'none')) return false;
|
||||
return !!ev.value.paymentState;
|
||||
});
|
||||
const paymentVariant = computed(() => {
|
||||
@@ -128,9 +132,13 @@ const paymentIcon = computed(() => {
|
||||
});
|
||||
const paymentLabel = computed(() => {
|
||||
const state = ev.value.paymentState;
|
||||
// Em sessão particular, valor mora em price. Em convênio, vai pra
|
||||
// insurance_value (price = null). Fallback cobre os dois casos.
|
||||
const valor = ev.value.price ?? ev.value.insurance_value;
|
||||
// Pra estado 'paid', usar o VALOR REAL pago (paymentAmount, vem do
|
||||
// financial_record). Em pacote upfront, é o package_price total —
|
||||
// o evento.price pode ter sido editado depois e divergir. Em outros
|
||||
// estados, fallback pro price/insurance_value do evento.
|
||||
const valor = state === 'paid' && ev.value.paymentAmount != null
|
||||
? ev.value.paymentAmount
|
||||
: (ev.value.price ?? ev.value.insurance_value);
|
||||
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
|
||||
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
: null;
|
||||
|
||||
@@ -881,23 +881,37 @@ async function confirmAnteciparPagamento() {
|
||||
async function onVerLancamentos() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.id) return;
|
||||
// Ocorrência virtual ainda não foi materializada — id é sintético
|
||||
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
|
||||
// Aborta sem query e avisa o user. 2026-05-14.
|
||||
// Ocorrência virtual: id é sintético `rec::<rule>::<date>`, não bate com
|
||||
// agenda_evento_id (uuid). Mas se a virtual pertence a uma série com
|
||||
// contrato upfront, os records do contrato (linkados a sibling
|
||||
// materializada) cobrem ela — busca via recurrence_id.
|
||||
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
|
||||
if (ev.is_occurrence || isVirtualId) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Sem lançamentos ainda',
|
||||
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
|
||||
life: 5000
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isVirtual = ev.is_occurrence || isVirtualId;
|
||||
|
||||
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
|
||||
lancamentosDialogOpen.value = true;
|
||||
lancamentosLoading.value = true;
|
||||
try {
|
||||
let records = [];
|
||||
if (isVirtual && ev.recurrence_id) {
|
||||
// Pega records de QUALQUER sibling materializada (cobre o caso
|
||||
// pacote upfront onde só a 1ª tem record do pacote inteiro).
|
||||
const { data: siblings } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ev.recurrence_id);
|
||||
const ids = (siblings || []).map((s) => s.id);
|
||||
if (ids.length) {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
|
||||
.in('agenda_evento_id', ids)
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: true });
|
||||
if (error) throw error;
|
||||
records = data || [];
|
||||
}
|
||||
} else if (!isVirtual) {
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
|
||||
@@ -905,7 +919,20 @@ async function onVerLancamentos() {
|
||||
.is('deleted_at', null)
|
||||
.order('created_at', { ascending: true });
|
||||
if (error) throw error;
|
||||
lancamentosList.value = data || [];
|
||||
records = data || [];
|
||||
}
|
||||
lancamentosList.value = records;
|
||||
if (!records.length && isVirtual) {
|
||||
// Caso virtual sem records de contrato (saldo, sem pacote): fecha
|
||||
// o dialog e avisa que ainda não materializou (comportamento antigo).
|
||||
lancamentosDialogOpen.value = false;
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Sem lançamentos ainda',
|
||||
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
|
||||
lancamentosList.value = [];
|
||||
|
||||
@@ -117,7 +117,7 @@ function isoToDecimalHour(iso) {
|
||||
return d.getHours() + d.getMinutes() / 60;
|
||||
}
|
||||
|
||||
function normalizeForMelissa(r, paymentStateMap = null) {
|
||||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = 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").
|
||||
const isOccurrence = !!r.is_occurrence;
|
||||
@@ -128,8 +128,17 @@ function normalizeForMelissa(r, paymentStateMap = null) {
|
||||
// _reloadRange via bulk-query em financial_records). Default 'none'
|
||||
// pra ocorrências virtuais (sem id real ainda) e eventos sem record.
|
||||
// 'paid' | 'pending' | 'none'.
|
||||
const paymentState =
|
||||
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || 'none';
|
||||
// Fallback pra virtuais ou reais sem record: herda do contrato upfront
|
||||
// pago da série via _rulePaymentMap (chave = recurrence_id).
|
||||
let paymentState =
|
||||
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null;
|
||||
let paymentAmount =
|
||||
!isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null;
|
||||
if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) {
|
||||
paymentState = rulePaymentMap[r.recurrence_id].state;
|
||||
if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount;
|
||||
}
|
||||
if (!paymentState) paymentState = 'none';
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -151,6 +160,7 @@ function normalizeForMelissa(r, paymentStateMap = null) {
|
||||
recurrence_id: r.recurrence_id ?? null,
|
||||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||||
paymentState,
|
||||
paymentAmount,
|
||||
price: r.price != null ? Number(r.price) : null,
|
||||
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
||||
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
||||
@@ -333,9 +343,17 @@ export function useMelissaAgenda() {
|
||||
// badge "$ pendente" no FC e linha "A receber" no popover sem ter que
|
||||
// queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'.
|
||||
const _paymentStateMap = ref({});
|
||||
// Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo
|
||||
// popover e Resumo do dialog quando paymentState='paid' pra mostrar o
|
||||
// valor REAL pago (vs evento.price que pode ter sido editado depois).
|
||||
const _paymentAmountMap = ref({});
|
||||
// Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não
|
||||
// têm id real e portanto não entram em _paymentStateMap), normalize lê
|
||||
// daqui o estado herdado do contrato upfront pago da série.
|
||||
const _rulePaymentMap = ref({});
|
||||
|
||||
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
|
||||
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value)));
|
||||
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value)));
|
||||
|
||||
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
|
||||
// `allEvents` no shape FC pra checar conflitos) ─────────
|
||||
@@ -462,16 +480,21 @@ export function useMelissaAgenda() {
|
||||
// Não bloqueia render — eventos aparecem sem badge até este await
|
||||
// resolver (default 'none').
|
||||
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
|
||||
if (realIds.length) {
|
||||
// Inicializa maps SEMPRE (não condicional a realIds.length>0) — em
|
||||
// semanas onde só há virtuais (sem eventos reais), ainda precisamos
|
||||
// rodar a propagação cross-week pra ruleMap.
|
||||
const map = {};
|
||||
const amountMap = {};
|
||||
const ruleMap = {};
|
||||
// Filtra cancelados: cobrança cancelada não deve manter
|
||||
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
||||
// como "sem cobrança ativa" → cai pro default 'none'.
|
||||
if (realIds.length) {
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('agenda_evento_id, paid_at, status')
|
||||
.select('agenda_evento_id, paid_at, status, amount, final_amount')
|
||||
.in('agenda_evento_id', realIds)
|
||||
.neq('status', 'cancelled');
|
||||
const map = {};
|
||||
for (const id of realIds) map[id] = 'none';
|
||||
for (const rec of recs || []) {
|
||||
const eid = rec.agenda_evento_id;
|
||||
@@ -481,14 +504,147 @@ export function useMelissaAgenda() {
|
||||
// consideramos cobrança honrada. Filhos (multas/taxas) que
|
||||
// venham pendentes não revertem esse estado pro badge.
|
||||
map[eid] = 'paid';
|
||||
amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
|
||||
} else if (map[eid] !== 'paid') {
|
||||
map[eid] = 'pending';
|
||||
if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais
|
||||
// na view), pra que virtuais isoladas em semanas futuras herdem o
|
||||
// estado do contrato cross-week. Antes estava dentro de
|
||||
// `if (realIds.length)` e falhava em semanas só com virtuais.
|
||||
//
|
||||
// Quando a 1ª materializada
|
||||
// de uma série tem record (paid OU pending), TODAS as ocorrências
|
||||
// do mesmo recurrence_id devem herdar o mesmo estado (cobertas
|
||||
// pelo contrato — cobrança única do pacote inteiro).
|
||||
// Funciona pra real rows; virtuais ficam por loadOccFinancialRecord
|
||||
// no occurrenceMode (não passam por este bulk).
|
||||
// Estratégia: pega rules dos eventos com record OU dos eventos
|
||||
// visíveis que têm recurrence_id (cross-week — record do pacote
|
||||
// pode estar em outra semana), busca contratos upfront ativos
|
||||
// pros pacientes, propaga estado pra siblings no view atual.
|
||||
try {
|
||||
// 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem
|
||||
// a uma série. Antes só usávamos eventos COM record paid/pending,
|
||||
// o que falhava cross-week (record do pacote pode estar em
|
||||
// outra semana). Agora propagação cobre Ana 2/3/4 mesmo
|
||||
// quando o record está só no Ana 1.
|
||||
const ruleIdsInView = new Set();
|
||||
for (const r of rows.value || []) {
|
||||
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
||||
}
|
||||
// Virtuais expandidas (que vêm depois) também trazem rules:
|
||||
for (const r of _occurrenceRows.value || []) {
|
||||
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
||||
}
|
||||
if (ruleIdsInView.size) {
|
||||
// 2) Acha QUALQUER evento (em qualquer semana) das rules
|
||||
// em view + seus records paid/pending pra detectar o
|
||||
// estado do contrato.
|
||||
const { data: allRuleEvents } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, recurrence_id, patient_id')
|
||||
.in('recurrence_id', [...ruleIdsInView]);
|
||||
const ruleEventIds = (allRuleEvents || []).map((e) => e.id);
|
||||
let ruleRecords = [];
|
||||
if (ruleEventIds.length) {
|
||||
const { data: rr } = await supabase
|
||||
.from('financial_records')
|
||||
.select('agenda_evento_id, paid_at, status')
|
||||
.in('agenda_evento_id', ruleEventIds)
|
||||
.neq('status', 'cancelled');
|
||||
ruleRecords = rr || [];
|
||||
}
|
||||
// Mapeia: rule_id → state (paid > pending > none) + patient
|
||||
const evIdToRule = new Map();
|
||||
const evIdToPatient = new Map();
|
||||
for (const e of allRuleEvents || []) {
|
||||
evIdToRule.set(e.id, e.recurrence_id);
|
||||
evIdToPatient.set(e.id, e.patient_id);
|
||||
}
|
||||
const ruleToPatient = new Map();
|
||||
const ruleToState = new Map();
|
||||
for (const rec of ruleRecords) {
|
||||
const rid = evIdToRule.get(rec.agenda_evento_id);
|
||||
const pid = evIdToPatient.get(rec.agenda_evento_id);
|
||||
if (!rid || !pid) continue;
|
||||
ruleToPatient.set(rid, pid);
|
||||
const newState = rec.paid_at ? 'paid' : 'pending';
|
||||
const existing = ruleToState.get(rid);
|
||||
if (existing !== 'paid') ruleToState.set(rid, newState);
|
||||
}
|
||||
if (ruleToPatient.size) {
|
||||
// Confirma contratos upfront pros pacientes envolvidos
|
||||
const patientIds = [...new Set(ruleToPatient.values())];
|
||||
const { data: contracts } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('patient_id, charging_style, status, type')
|
||||
.in('patient_id', patientIds);
|
||||
const activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
|
||||
// NULL charging_style → assume upfront (default histórico
|
||||
// antes da migration 20260514000003). Pra dados antigos
|
||||
// sem a coluna preenchida, evita virtuais ficarem sem
|
||||
// propagação. 'saldo' explícito mantém siblings 'none'.
|
||||
const upfrontPatients = new Set(
|
||||
activePackages
|
||||
.filter((c) => c.charging_style === 'upfront' || c.charging_style == null)
|
||||
.map((c) => c.patient_id)
|
||||
);
|
||||
const ruleIdsToPropagate = [...ruleToPatient.entries()]
|
||||
.filter(([, pid]) => upfrontPatients.has(pid))
|
||||
.map(([rid]) => rid);
|
||||
if (ruleIdsToPropagate.length) {
|
||||
const { data: siblings } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, recurrence_id')
|
||||
.in('recurrence_id', ruleIdsToPropagate);
|
||||
// Captura o package_price do contrato pra propagar
|
||||
// valor real pra siblings (não o per-session que
|
||||
// pode ter sido editado depois)
|
||||
const contractPriceByPatient = new Map();
|
||||
const { data: contractsDetail } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('patient_id, package_price')
|
||||
.in('patient_id', [...upfrontPatients])
|
||||
.eq('charging_style', 'upfront')
|
||||
.eq('status', 'active');
|
||||
for (const c of contractsDetail || []) {
|
||||
contractPriceByPatient.set(c.patient_id, c.package_price);
|
||||
}
|
||||
for (const s of siblings || []) {
|
||||
if (map[s.id] !== undefined) {
|
||||
const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending';
|
||||
// Não rebaixa: se sibling já está paid
|
||||
// (record próprio paid), mantém.
|
||||
if (map[s.id] !== 'paid') map[s.id] = stateToPropagate;
|
||||
const pid = ruleToPatient.get(s.recurrence_id);
|
||||
const price = contractPriceByPatient.get(pid);
|
||||
if (price != null) amountMap[s.id] = price;
|
||||
}
|
||||
}
|
||||
// Populate _rulePaymentMap pra que VIRTUAIS (ainda
|
||||
// não materializadas) também herdem o estado ao
|
||||
// passar pelo normalize.
|
||||
for (const rid of ruleIdsToPropagate) {
|
||||
const pid = ruleToPatient.get(rid);
|
||||
const price = contractPriceByPatient.get(pid);
|
||||
const state = ruleToState.get(rid) || 'pending';
|
||||
ruleMap[rid] = { state, amount: price ?? null };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message);
|
||||
}
|
||||
|
||||
_paymentStateMap.value = map;
|
||||
} else {
|
||||
_paymentStateMap.value = {};
|
||||
}
|
||||
_paymentAmountMap.value = amountMap;
|
||||
_rulePaymentMap.value = ruleMap;
|
||||
}
|
||||
|
||||
async function refetch() {
|
||||
@@ -2130,7 +2286,9 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
|
||||
// charging_style: identifica como o pacote foi cobrado na criação;
|
||||
// handler de status change usa pra decidir entre "só status" (upfront)
|
||||
// ou "criar cobrança + consumir saldo" (saldo).
|
||||
const { error: contractErr } = await supabase.from('billing_contracts').insert({
|
||||
const { data: createdContract, error: contractErr } = await supabase
|
||||
.from('billing_contracts')
|
||||
.insert({
|
||||
owner_id: normalized.owner_id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: normalized.paciente_id,
|
||||
@@ -2140,8 +2298,11 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
|
||||
package_price: packagePrice,
|
||||
status: 'active',
|
||||
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
|
||||
});
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (contractErr) throw contractErr;
|
||||
const contractId = createdContract?.id ?? null;
|
||||
|
||||
// Estilo 'saldo': para aqui — sem cobrança imediata.
|
||||
if (packageStyle === 'saldo') {
|
||||
@@ -2180,6 +2341,7 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
|
||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
||||
price: packagePrice,
|
||||
billing_contract_id: contractId,
|
||||
visibility_scope: normalized.visibility_scope || 'public'
|
||||
})
|
||||
.select('id')
|
||||
|
||||
Reference in New Issue
Block a user