@fayz-ai/plugin-clinical 0.4.13 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3757 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_001_O_ATENDIMENTO_CLINICO_TEM_ONDE_MORAR = `-- O atendimento cl\xEDnico tem onde morar.
3
+ --
4
+ -- 358.577 linhas do Espa\xE7o Facial n\xE3o tinham destino nenhum no V2 \u2014 96% de tudo
5
+ -- que ainda falta portar daquele tenant. N\xE3o era uma tabela esquecida: era um
6
+ -- DOM\xCDNIO inteiro sem casa.
7
+ --
8
+ -- journey_executions 233.934 a sess\xE3o de execu\xE7\xE3o
9
+ -- execution_products 117.011 o produto consumido na sess\xE3o
10
+ -- execution_requirements 6.590 o que trava come\xE7ar e terminar
11
+ -- service_revisions 110 a vers\xE3o do servi\xE7o (pre\xE7o, prazo)
12
+ -- service_revision_forms 87 os formul\xE1rios daquela vers\xE3o
13
+ -- prepaid_item_outcomes 30 o desfecho do item contratado
14
+ --
15
+ -- \u2500\u2500 Por que n\xE3o \xE9 a agenda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
16
+ --
17
+ -- A agenda termina em QUEM, QUANDO e ONDE \u2014 \`appointments\` e
18
+ -- \`appointment_items\`, que j\xE1 atravessaram na migration 008. O que est\xE1 aqui \xE9
19
+ -- O QUE FOI FEITO, e tem ciclo de vida pr\xF3prio: revis\xE3o com pai
20
+ -- (\`parent_execution_id\`), requisitos que bloqueiam in\xEDcio e fim, consumo de
21
+ -- produto auditado contra o estoque, foto por pose.
22
+ --
23
+ -- A jornada est\xE1 documentada em
24
+ -- ~/dev/beautyplace/docs/v1-guide/jornadas/avaliacao-e-contratacao/FLOW.md
25
+ -- (\`v2.status: missing\`): anamnese COM VALIDADE \u2192 avalia\xE7\xE3o com desenho do rosto
26
+ -- e foto \u2192 indica\xE7\xE3o com quantidade \u2192 "o que o cliente deseja contratar" \u2192
27
+ -- contrato assinado e termo de sess\xE3o \u2192 executar s\xF3 o que couber hoje \u2192 r\xE9gua de
28
+ -- etapas com cron\xF4metro e evolu\xE7\xE3o cl\xEDnica.
29
+ --
30
+ -- \xC9 capacidade de VERTICAL (est\xE9tica, sa\xFAde), n\xE3o do n\xFAcleo. Por isso plugin
31
+ -- pr\xF3prio e entrada em \`optional\` no chain.json, como o workforce.
32
+ --
33
+ -- \u2500\u2500 O que N\xC3O \xE9 deste dom\xEDnio, apesar do nome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
34
+ --
35
+ -- \`journeys\` e \`journey_steps\` compartilham o prefixo com \`journey_executions\` e
36
+ -- n\xE3o t\xEAm rela\xE7\xE3o nenhuma com ele: ZERO chave estrangeira entre os dois, e
37
+ -- \`journey_steps\` aponta para \`message_templates\` carregando \`wait_value\`,
38
+ -- \`channel\`, \`quiet_hours_start\` e \`cooldown_days\`. \xC9 automa\xE7\xE3o de COMUNICA\xC7\xC3O,
39
+ -- e o destino dela \xE9 \`plg_automations_flows\`, que j\xE1 existe. S\xE3o 7 linhas, e a
40
+ -- tela delas no V1 nem abre (V1-027).
41
+ --
42
+ -- \u2500\u2500 As colunas que ficaram para tr\xE1s, e por qu\xEA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
43
+ --
44
+ -- Medido no tenant 1 em 21/09/2026:
45
+ --
46
+ -- \`execution_products.stock_deducted\` 0 de 117.015 verdadeiras
47
+ -- \`execution_products.stock_movement_id\` 0 preenchidas
48
+ -- \`execution_products.stock_position_id\` 0 preenchidas
49
+ -- \`position_allocations\` / \`unit_label_allocations\` 0 preenchidas
50
+ --
51
+ -- O consumo NUNCA deu baixa no estoque neste tenant. Isso n\xE3o \xE9 defeito do
52
+ -- porte: \xE9 o que o pr\xF3prio Guia do V1 descreve na jornada
53
+ -- \`auditoria-de-uso-e-estoque\`, onde a auditoria \xE9 port\xE3o PR\xC9-baixa e a baixa
54
+ -- fica pendurada. Trazer colunas vazias faria a tela do V2 afirmar que existe um
55
+ -- v\xEDnculo com o raz\xE3o que nunca existiu. O dia em que houver baixa, ela vira
56
+ -- \`plg_inventory_stock_movements\` com \`subject_id\` apontando para a sess\xE3o.
57
+ --
58
+ -- \`journey_executions.product_id\` 0 de 233.950 \u2014 coluna morta
59
+ -- \`journey_executions.status\` redundante com \`journey_status\`, que
60
+ -- \xE9 mais granular (separa quoted de
61
+ -- contracted). Vai para metadata.
62
+ -- \`execution_photos\` / \`consumption_photos\` s\xE3o ARQUIVO, e arquivo no V2 \xE9
63
+ -- \`public.documents\`. S\xE3o muitos por sess\xE3o, cada um com a sua pose, ent\xE3o o
64
+ -- v\xEDnculo \xE9 \`documents.subject_type\`/\`subject_id\` \u2014 e n\xE3o uma coluna de
65
+ -- array que nenhuma RLS de arquivo alcan\xE7a.
66
+ --
67
+ -- Idempotente.
68
+
69
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
70
+ -- 1. A sess\xE3o
71
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
72
+
73
+ CREATE TABLE IF NOT EXISTS public.plg_clinical_sessions (
74
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
75
+ tenant_id uuid NOT NULL,
76
+ unit_id uuid,
77
+ -- O \`journey_status\` do V1. Fechado num CHECK porque \xE9 vocabul\xE1rio de
78
+ -- plugin, n\xE3o arqu\xE9tipo \u2014 \`schedule_kinds\`/\`appointment_kinds\` s\xE3o registros
79
+ -- de esp\xE9cie, e uma sess\xE3o cl\xEDnica n\xE3o \xE9 esp\xE9cie de nada.
80
+ status text DEFAULT 'pending'::text NOT NULL,
81
+ -- O agendamento: 226.551 de 233.950 (96,8%). Os que faltam s\xE3o sess\xE3o sem
82
+ -- agenda \u2014 contratada e ainda n\xE3o marcada.
83
+ booking_id uuid,
84
+ -- O item do agendamento: 2.511 de 233.950. Raro no V1 e mantido assim: o
85
+ -- v\xEDnculo forte com o que foi vendido \xE9 \`order_item_id\`.
86
+ booking_item_id uuid,
87
+ party_id uuid,
88
+ -- Quem estava marcado e quem de fato executou s\xE3o duas perguntas. O V1
89
+ -- guarda as duas (\`professional_id\`, \`execution_professional_id\`) e a
90
+ -- segunda s\xF3 existe em 148.414 \u2014 \xE9 ela que a comiss\xE3o l\xEA.
91
+ assignee_id uuid,
92
+ performed_by_id uuid,
93
+ service_id uuid,
94
+ order_item_id uuid,
95
+ quote_item_id uuid,
96
+ -- 233.281 de 233.950 (99,7%) t\xEAm oportunidade. A sess\xE3o pendura no NEG\xD3CIO
97
+ -- antes de pendurar na agenda, e \xE9 por isso que ela existe para o que foi
98
+ -- or\xE7ado e nunca marcado.
99
+ deal_id uuid,
100
+ revision_number integer DEFAULT 1 NOT NULL,
101
+ parent_session_id uuid,
102
+ source_evaluation_id uuid,
103
+ scheduled_for date,
104
+ started_at timestamp with time zone,
105
+ ended_at timestamp with time zone,
106
+ products_initialized boolean DEFAULT false NOT NULL,
107
+ notes text,
108
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
109
+ owner_id uuid,
110
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
111
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
112
+ CONSTRAINT plg_clinical_sessions_status_check CHECK (status = ANY (ARRAY[
113
+ 'quoted'::text, 'contracted'::text, 'scheduled'::text, 'in_service'::text,
114
+ 'in_progress'::text, 'executed'::text, 'pending'::text])),
115
+ CONSTRAINT plg_clinical_sessions_revision_check CHECK (revision_number >= 1),
116
+ -- A sess\xE3o termina depois de come\xE7ar. O V1 n\xE3o cobra isso e por isso a
117
+ -- recusa vive no writer do porte, n\xE3o aqui \u2014 mas daqui para frente vale.
118
+ CONSTRAINT plg_clinical_sessions_window_check
119
+ CHECK (started_at IS NULL OR ended_at IS NULL OR ended_at >= started_at)
120
+ );
121
+
122
+ COMMENT ON TABLE public.plg_clinical_sessions IS
123
+ 'A sess\xE3o de execu\xE7\xE3o do atendimento cl\xEDnico. V1: journey_executions \u2014 que N\xC3O tem rela\xE7\xE3o com journeys, apesar do nome.';
124
+ COMMENT ON COLUMN public.plg_clinical_sessions.performed_by_id IS
125
+ 'V1: execution_professional_id \u2014 quem de fato executou, que pode n\xE3o ser quem estava marcado.';
126
+ COMMENT ON COLUMN public.plg_clinical_sessions.source_evaluation_id IS
127
+ 'V1: source_evaluation_execution_id \u2014 a avalia\xE7\xE3o que originou esta indica\xE7\xE3o.';
128
+
129
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
130
+ -- 2. O produto consumido
131
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
132
+
133
+ CREATE TABLE IF NOT EXISTS public.plg_clinical_session_products (
134
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
135
+ tenant_id uuid NOT NULL,
136
+ unit_id uuid,
137
+ session_id uuid NOT NULL,
138
+ product_id uuid NOT NULL,
139
+ order_item_id uuid,
140
+ assignee_id uuid,
141
+ -- O "Padr\xE3o: N" da ficha t\xE9cnica contra o que a profissional realmente usou.
142
+ -- \xC9 a diferen\xE7a entre os dois que a auditoria de consumo l\xEA.
143
+ default_quantity numeric(14,4),
144
+ used_quantity numeric(14,4),
145
+ used_unit_id uuid,
146
+ executed_on date,
147
+ is_audited boolean DEFAULT false NOT NULL,
148
+ audited_at timestamp with time zone,
149
+ audited_by uuid,
150
+ audit_notes text,
151
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
152
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
153
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
154
+ CONSTRAINT plg_clinical_session_products_qty_check
155
+ CHECK (used_quantity IS NULL OR used_quantity >= 0)
156
+ );
157
+
158
+ COMMENT ON TABLE public.plg_clinical_session_products IS
159
+ 'O produto consumido na sess\xE3o, com o padr\xE3o da ficha t\xE9cnica ao lado do usado. V1: execution_products. As colunas de baixa de estoque n\xE3o vieram: 0 de 117.015 estavam preenchidas.';
160
+
161
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
162
+ -- 3. O que trava come\xE7ar e terminar
163
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
164
+
165
+ CREATE TABLE IF NOT EXISTS public.plg_clinical_requirements (
166
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
167
+ tenant_id uuid NOT NULL,
168
+ unit_id uuid,
169
+ session_id uuid,
170
+ booking_id uuid,
171
+ party_id uuid,
172
+ service_id uuid,
173
+ kind text NOT NULL,
174
+ scope text NOT NULL,
175
+ -- As duas travas do V1, e elas s\xE3o diferentes: a anamnese impede COME\xC7AR, o
176
+ -- termo de sess\xE3o impede TERMINAR. 1.107 e 1.023 linhas no tenant 1.
177
+ blocks_start boolean DEFAULT false NOT NULL,
178
+ blocks_finish boolean DEFAULT false NOT NULL,
179
+ status text DEFAULT 'pending'::text NOT NULL,
180
+ -- O que resolveu a exig\xEAncia \xE9 polim\xF3rfico no V1 (\`resolved_table\` +
181
+ -- \`resolved_id\`) e continua sendo aqui, com o mesmo par que
182
+ -- \`public.documents\` j\xE1 usa. Um formul\xE1rio resolve por uma linha de
183
+ -- plg_forms_documents; um contrato, por outra.
184
+ subject_type text,
185
+ subject_id uuid,
186
+ candidate_type text,
187
+ candidate_id uuid,
188
+ resolved_via text,
189
+ resolved_at timestamp with time zone,
190
+ -- A anamnese vale at\xE9 uma data ("V\xE1lido at\xE9 08/03/2027"), e \xE9 isso que
191
+ -- solta o resto da r\xE9gua.
192
+ valid_until timestamp with time zone,
193
+ frozen_at timestamp with time zone,
194
+ rule_sources jsonb DEFAULT '[]'::jsonb NOT NULL,
195
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
196
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
197
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
198
+ CONSTRAINT plg_clinical_requirements_kind_check CHECK (kind = ANY (ARRAY[
199
+ 'form'::text, 'evaluation'::text, 'contract'::text, 'session_term'::text,
200
+ 'products'::text, 'invoice_item'::text, 'revision_link'::text])),
201
+ CONSTRAINT plg_clinical_requirements_scope_check
202
+ CHECK (scope = ANY (ARRAY['service'::text, 'attendance'::text])),
203
+ CONSTRAINT plg_clinical_requirements_status_check CHECK (status = ANY (ARRAY[
204
+ 'pending'::text, 'resolved'::text, 'not_applicable'::text]))
205
+ );
206
+
207
+ COMMENT ON TABLE public.plg_clinical_requirements IS
208
+ 'A exig\xEAncia que trava o in\xEDcio ou o fim do atendimento \u2014 anamnese, avalia\xE7\xE3o, contrato, termo, kit. V1: execution_requirements.';
209
+
210
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
211
+ -- 4. A vers\xE3o do servi\xE7o
212
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
213
+ --
214
+ -- O servi\xE7o muda de pre\xE7o e de prazo, e o que foi contratado tem de continuar
215
+ -- valendo o que valia. A revis\xE3o \xE9 o congelamento \u2014 e ela carrega os
216
+ -- formul\xE1rios que aquela vers\xE3o exigia.
217
+ CREATE TABLE IF NOT EXISTS public.plg_clinical_service_revisions (
218
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
219
+ tenant_id uuid NOT NULL,
220
+ service_id uuid NOT NULL,
221
+ revision_number integer NOT NULL,
222
+ price numeric(14,2),
223
+ duration_minutes integer,
224
+ deadline_days integer,
225
+ -- Os \`service_revision_forms\` daquela revis\xE3o. S\xE3o 87 linhas em 110
226
+ -- revis\xF5es: uma lista curta, sem vida pr\xF3pria e sem tela \u2014 jsonb em vez de
227
+ -- uma quarta tabela que ningu\xE9m consulta sozinha.
228
+ form_template_refs jsonb DEFAULT '[]'::jsonb NOT NULL,
229
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
230
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
231
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
232
+ CONSTRAINT plg_clinical_service_revisions_number_check CHECK (revision_number >= 1)
233
+ );
234
+
235
+ COMMENT ON TABLE public.plg_clinical_service_revisions IS
236
+ 'A vers\xE3o congelada do servi\xE7o \u2014 pre\xE7o, dura\xE7\xE3o e prazo no momento da contrata\xE7\xE3o. V1: service_revisions + service_revision_forms.';
237
+
238
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
239
+ -- 5. O desfecho do que foi contratado e n\xE3o executado
240
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
241
+
242
+ CREATE TABLE IF NOT EXISTS public.plg_clinical_prepaid_outcomes (
243
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
244
+ tenant_id uuid NOT NULL,
245
+ unit_id uuid,
246
+ booking_id uuid,
247
+ order_item_id uuid,
248
+ outcome text NOT NULL,
249
+ reason text,
250
+ decided_by uuid,
251
+ decided_at timestamp with time zone DEFAULT now() NOT NULL,
252
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
253
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
254
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
255
+ CONSTRAINT plg_clinical_prepaid_outcomes_check CHECK (outcome = ANY (ARRAY[
256
+ 'contracted'::text, 'declined'::text, 'postponed'::text]))
257
+ );
258
+
259
+ COMMENT ON TABLE public.plg_clinical_prepaid_outcomes IS
260
+ 'O que aconteceu com o servi\xE7o contratado e n\xE3o executado no dia \u2014 contratado, recusado ou adiado. V1: prepaid_item_outcomes.';
261
+
262
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
263
+ -- 6. As chaves
264
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
265
+ --
266
+ -- Toda FK para uma tabela escopada por unidade \xE9 COMPOSTA (tenant_id, id): \xE9
267
+ -- ela que impede a sess\xE3o de uma casa apontar para o agendamento de outra. Onde
268
+ -- a refer\xEAncia \xE9 opcional, \`ON DELETE SET NULL\` \u2014 apagar o agendamento n\xE3o pode
269
+ -- levar o prontu\xE1rio junto.
270
+ DO $$
271
+ DECLARE
272
+ t text;
273
+ tabelas text[] := ARRAY['plg_clinical_sessions', 'plg_clinical_session_products',
274
+ 'plg_clinical_requirements', 'plg_clinical_service_revisions',
275
+ 'plg_clinical_prepaid_outcomes'];
276
+ BEGIN
277
+ FOREACH t IN ARRAY tabelas LOOP
278
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
279
+ WHERE conrelid = ('public.' || t)::regclass AND contype = 'p') THEN
280
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I PRIMARY KEY (id)', t, t || '_pkey');
281
+ END IF;
282
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_id_id_key') THEN
283
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I UNIQUE (tenant_id, id)', t, t || '_tenant_id_id_key');
284
+ END IF;
285
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_fkey') THEN
286
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE', t, t || '_tenant_fkey');
287
+ END IF;
288
+ END LOOP;
289
+ END $$;
290
+
291
+ DO $$ BEGIN
292
+ -- sess\xF5es
293
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_unit_fkey') THEN
294
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_unit_fkey
295
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
296
+ END IF;
297
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_booking_fkey') THEN
298
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_booking_fkey
299
+ FOREIGN KEY (booking_id) REFERENCES public.appointments(id) ON DELETE SET NULL;
300
+ END IF;
301
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_booking_item_fkey') THEN
302
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_booking_item_fkey
303
+ FOREIGN KEY (booking_item_id) REFERENCES public.appointment_items(id) ON DELETE SET NULL;
304
+ END IF;
305
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_party_fkey') THEN
306
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_party_fkey
307
+ FOREIGN KEY (party_id) REFERENCES public.people(id) ON DELETE SET NULL;
308
+ END IF;
309
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_assignee_fkey') THEN
310
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_assignee_fkey
311
+ FOREIGN KEY (assignee_id) REFERENCES public.people(id) ON DELETE SET NULL;
312
+ END IF;
313
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_performed_by_fkey') THEN
314
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_performed_by_fkey
315
+ FOREIGN KEY (performed_by_id) REFERENCES public.people(id) ON DELETE SET NULL;
316
+ END IF;
317
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_service_fkey') THEN
318
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_service_fkey
319
+ FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE SET NULL;
320
+ END IF;
321
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_order_item_fkey') THEN
322
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_order_item_fkey
323
+ FOREIGN KEY (order_item_id) REFERENCES public.items(id) ON DELETE SET NULL;
324
+ END IF;
325
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_quote_item_fkey') THEN
326
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_quote_item_fkey
327
+ FOREIGN KEY (quote_item_id) REFERENCES public.items(id) ON DELETE SET NULL;
328
+ END IF;
329
+ -- O pai e a avalia\xE7\xE3o de origem s\xE3o a MESMA tabela. CASCADE aqui apagaria a
330
+ -- corrente de revis\xF5es inteira ao apagar a primeira; SET NULL deixa as filhas
331
+ -- \xF3rf\xE3s e leg\xEDveis, que \xE9 o que um prontu\xE1rio pede.
332
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_parent_fkey') THEN
333
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_parent_fkey
334
+ FOREIGN KEY (parent_session_id) REFERENCES public.plg_clinical_sessions(id) ON DELETE SET NULL;
335
+ END IF;
336
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_sessions_source_eval_fkey') THEN
337
+ ALTER TABLE ONLY public.plg_clinical_sessions ADD CONSTRAINT plg_clinical_sessions_source_eval_fkey
338
+ FOREIGN KEY (source_evaluation_id) REFERENCES public.plg_clinical_sessions(id) ON DELETE SET NULL;
339
+ END IF;
340
+
341
+ -- produtos da sess\xE3o: a sess\xE3o some, o consumo some junto \u2014 ele n\xE3o existe fora dela
342
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_session_products_session_fkey') THEN
343
+ ALTER TABLE ONLY public.plg_clinical_session_products ADD CONSTRAINT plg_clinical_session_products_session_fkey
344
+ FOREIGN KEY (tenant_id, session_id) REFERENCES public.plg_clinical_sessions(tenant_id, id) ON DELETE CASCADE;
345
+ END IF;
346
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_session_products_product_fkey') THEN
347
+ ALTER TABLE ONLY public.plg_clinical_session_products ADD CONSTRAINT plg_clinical_session_products_product_fkey
348
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
349
+ END IF;
350
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_session_products_unit_fkey') THEN
351
+ ALTER TABLE ONLY public.plg_clinical_session_products ADD CONSTRAINT plg_clinical_session_products_unit_fkey
352
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
353
+ END IF;
354
+ -- A UNIDADE DE MEDIDA (SR, UI, mL) MORA NO ESTOQUE, E FICA SEM CHAVE.
355
+ --
356
+ -- Havia uma FK para \`plg_inventory_measurement_units\`. Ela amarra dois ciclos
357
+ -- de release e torna imposs\xEDvel desinstalar o estoque sem derrubar o
358
+ -- prontu\xE1rio \u2014 a cl\xEDnica que n\xE3o usa estoque passaria a precisar dele. O
359
+ -- \`used_unit_id\` continua sendo o uuid de l\xE1; quem quiser o s\xEDmbolo resolve
360
+ -- na leitura, e unidade apagada vira n\xFAmero sem r\xF3tulo em vez de escrita
361
+ -- recusada.
362
+ ALTER TABLE public.plg_clinical_session_products
363
+ DROP CONSTRAINT IF EXISTS plg_clinical_session_products_uom_fkey;
364
+
365
+ -- exig\xEAncias
366
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_requirements_session_fkey') THEN
367
+ ALTER TABLE ONLY public.plg_clinical_requirements ADD CONSTRAINT plg_clinical_requirements_session_fkey
368
+ FOREIGN KEY (tenant_id, session_id) REFERENCES public.plg_clinical_sessions(tenant_id, id) ON DELETE CASCADE;
369
+ END IF;
370
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_requirements_booking_fkey') THEN
371
+ ALTER TABLE ONLY public.plg_clinical_requirements ADD CONSTRAINT plg_clinical_requirements_booking_fkey
372
+ FOREIGN KEY (booking_id) REFERENCES public.appointments(id) ON DELETE CASCADE;
373
+ END IF;
374
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_requirements_unit_fkey') THEN
375
+ ALTER TABLE ONLY public.plg_clinical_requirements ADD CONSTRAINT plg_clinical_requirements_unit_fkey
376
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
377
+ END IF;
378
+
379
+ -- revis\xE3o do servi\xE7o: uma por (servi\xE7o, n\xFAmero)
380
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_service_revisions_service_fkey') THEN
381
+ ALTER TABLE ONLY public.plg_clinical_service_revisions ADD CONSTRAINT plg_clinical_service_revisions_service_fkey
382
+ FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE CASCADE;
383
+ END IF;
384
+
385
+ -- desfecho
386
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_prepaid_outcomes_booking_fkey') THEN
387
+ ALTER TABLE ONLY public.plg_clinical_prepaid_outcomes ADD CONSTRAINT plg_clinical_prepaid_outcomes_booking_fkey
388
+ FOREIGN KEY (booking_id) REFERENCES public.appointments(id) ON DELETE CASCADE;
389
+ END IF;
390
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_prepaid_outcomes_item_fkey') THEN
391
+ ALTER TABLE ONLY public.plg_clinical_prepaid_outcomes ADD CONSTRAINT plg_clinical_prepaid_outcomes_item_fkey
392
+ FOREIGN KEY (order_item_id) REFERENCES public.items(id) ON DELETE SET NULL;
393
+ END IF;
394
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_clinical_prepaid_outcomes_unit_fkey') THEN
395
+ ALTER TABLE ONLY public.plg_clinical_prepaid_outcomes ADD CONSTRAINT plg_clinical_prepaid_outcomes_unit_fkey
396
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
397
+ END IF;
398
+ END $$;
399
+
400
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_clinical_service_revisions_service_number_key
401
+ ON public.plg_clinical_service_revisions USING btree (tenant_id, service_id, revision_number);
402
+
403
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
404
+ -- 7. Os \xEDndices das tr\xEAs perguntas que a tela faz
405
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
406
+ --
407
+ -- "o que aconteceu com esta paciente" \xB7 "o que falta neste atendimento" \xB7
408
+ -- "quanto deste produto saiu". Com 233 mil sess\xF5es numa rede de 63 unidades,
409
+ -- cada uma dessas sem \xEDndice \xE9 uma varredura.
410
+ CREATE INDEX IF NOT EXISTS plg_clinical_sessions_party_idx
411
+ ON public.plg_clinical_sessions USING btree (tenant_id, party_id, scheduled_for DESC);
412
+ CREATE INDEX IF NOT EXISTS plg_clinical_sessions_booking_idx
413
+ ON public.plg_clinical_sessions USING btree (tenant_id, booking_id) WHERE booking_id IS NOT NULL;
414
+ CREATE INDEX IF NOT EXISTS plg_clinical_sessions_unit_status_idx
415
+ ON public.plg_clinical_sessions USING btree (tenant_id, unit_id, status, scheduled_for DESC);
416
+ CREATE INDEX IF NOT EXISTS plg_clinical_sessions_deal_idx
417
+ ON public.plg_clinical_sessions USING btree (tenant_id, deal_id) WHERE deal_id IS NOT NULL;
418
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_session_idx
419
+ ON public.plg_clinical_session_products USING btree (tenant_id, session_id);
420
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_product_idx
421
+ ON public.plg_clinical_session_products USING btree (tenant_id, product_id, executed_on DESC);
422
+ CREATE INDEX IF NOT EXISTS plg_clinical_requirements_session_idx
423
+ ON public.plg_clinical_requirements USING btree (tenant_id, session_id, status);
424
+ CREATE INDEX IF NOT EXISTS plg_clinical_requirements_booking_pending_idx
425
+ ON public.plg_clinical_requirements USING btree (tenant_id, booking_id)
426
+ WHERE status = 'pending'::text;
427
+
428
+ DO $$
429
+ DECLARE t text;
430
+ BEGIN
431
+ FOREACH t IN ARRAY ARRAY['plg_clinical_sessions', 'plg_clinical_session_products',
432
+ 'plg_clinical_requirements', 'plg_clinical_service_revisions',
433
+ 'plg_clinical_prepaid_outcomes'] LOOP
434
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);
435
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', t || '_updated_at', t);
436
+ END LOOP;
437
+ END $$;
438
+
439
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
440
+ -- 8. Quem enxerga o prontu\xE1rio
441
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
442
+ --
443
+ -- Escopo de UNIDADE nas quatro tabelas que t\xEAm unidade, e permiss\xE3o pr\xF3pria:
444
+ -- \`clinical.read\` n\xE3o \xE9 \`agenda.read\`. Quem marca hor\xE1rio n\xE3o l\xEA anamnese.
445
+ --
446
+ -- A revis\xE3o do servi\xE7o \xE9 a exce\xE7\xE3o: ela \xE9 do CAT\xC1LOGO, n\xE3o de uma filial \u2014 o
447
+ -- pre\xE7o congelado de um servi\xE7o vale na rede inteira, e escond\xEA-lo por unidade
448
+ -- faria a fatura de ontem virar um n\xFAmero sem origem.
449
+ -- As cinco, uma a uma e por extenso. O la\xE7o abaixo j\xE1 liga tudo isto \u2014 mas ele
450
+ -- liga por \`EXECUTE format(...)\`, e o port\xE3o que revisa plugin l\xEA o ARQUIVO,
451
+ -- n\xE3o o banco: para ele, tabela com tenant_id e sem um ENABLE literal \xE9 tabela
452
+ -- aberta. Uma garantia que o revisor n\xE3o consegue ler \xE9 uma garantia que
453
+ -- ningu\xE9m confere.
454
+ ALTER TABLE public.plg_clinical_sessions ENABLE ROW LEVEL SECURITY;
455
+ ALTER TABLE public.plg_clinical_session_products ENABLE ROW LEVEL SECURITY;
456
+ ALTER TABLE public.plg_clinical_requirements ENABLE ROW LEVEL SECURITY;
457
+ ALTER TABLE public.plg_clinical_service_revisions ENABLE ROW LEVEL SECURITY;
458
+ ALTER TABLE public.plg_clinical_prepaid_outcomes ENABLE ROW LEVEL SECURITY;
459
+
460
+ DO $$
461
+ DECLARE
462
+ t text;
463
+ unidade boolean;
464
+ BEGIN
465
+ FOREACH t IN ARRAY ARRAY['plg_clinical_sessions', 'plg_clinical_session_products',
466
+ 'plg_clinical_requirements', 'plg_clinical_service_revisions',
467
+ 'plg_clinical_prepaid_outcomes'] LOOP
468
+ unidade := t <> 'plg_clinical_service_revisions';
469
+ EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);
470
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
471
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO authenticated', t);
472
+ EXECUTE format('GRANT ALL ON TABLE public.%I TO service_role', t);
473
+
474
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_select', t);
475
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (
476
+ tenant_id = (SELECT app.current_tenant_id())
477
+ AND (%s)
478
+ AND (SELECT app.has_permission('clinical.read', %s)))$f$,
479
+ t || '_authz_select', t,
480
+ CASE WHEN unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,
481
+ CASE WHEN unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
482
+
483
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_insert', t);
484
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (
485
+ tenant_id = (SELECT app.current_tenant_id())
486
+ AND (%s)
487
+ AND (SELECT app.has_permission('clinical.create', %s)))$f$,
488
+ t || '_authz_insert', t,
489
+ CASE WHEN unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,
490
+ CASE WHEN unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
491
+
492
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_update', t);
493
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (
494
+ tenant_id = (SELECT app.current_tenant_id())
495
+ AND (%s)
496
+ AND (SELECT app.has_permission('clinical.edit', %s)))
497
+ WITH CHECK (
498
+ tenant_id = (SELECT app.current_tenant_id())
499
+ AND (SELECT app.has_permission('clinical.edit', %s)))$f$,
500
+ t || '_authz_update', t,
501
+ CASE WHEN unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,
502
+ CASE WHEN unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END,
503
+ CASE WHEN unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
504
+
505
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_delete', t);
506
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (
507
+ tenant_id = (SELECT app.current_tenant_id())
508
+ AND (%s)
509
+ AND (SELECT app.has_permission('clinical.delete', %s)))$f$,
510
+ t || '_authz_delete', t,
511
+ CASE WHEN unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,
512
+ CASE WHEN unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
513
+ END LOOP;
514
+ END $$;
515
+
516
+ -- \`owner_id\` s\xF3 na sess\xE3o, e \`shareable\` falso: um prontu\xE1rio n\xE3o se compartilha
517
+ -- por tupla \u2014 ou a pessoa atende naquela unidade, ou n\xE3o l\xEA.
518
+ INSERT INTO public.scoped_resources (resource_table, unit_column, owner_column, shareable, default_visibility, label)
519
+ VALUES ('plg_clinical_sessions', 'unit_id', 'owner_id', false, 'unit', 'Atendimentos cl\xEDnicos')
520
+ ON CONFLICT (resource_table) DO UPDATE
521
+ SET unit_column = EXCLUDED.unit_column, default_visibility = EXCLUDED.default_visibility, label = EXCLUDED.label;
522
+
523
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
524
+ -- 9. A permiss\xE3o existe antes da pol\xEDtica precisar dela
525
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
526
+ --
527
+ -- \`clinical.read\` n\xE3o \xE9 \`agenda.read\`, e a diferen\xE7a \xE9 o ponto: a recep\xE7\xE3o marca
528
+ -- hor\xE1rio e N\xC3O l\xEA anamnese. Quem hoje tem \`agenda.*\` num papel herda o
529
+ -- equivalente cl\xEDnico, porque \xE9 quem atende \u2014 e quem quiser separar recep\xE7\xE3o de
530
+ -- profissional tira a linha do papel, que \xE9 uma decis\xE3o de uma tela, n\xE3o de uma
531
+ -- migration.
532
+ INSERT INTO app.permissions (key, plugin, description, owner_only) VALUES
533
+ ('clinical.read', 'clinical', 'Prontu\xE1rio: ler o atendimento, a anamnese e o consumo', false),
534
+ ('clinical.read_all', 'clinical', 'Prontu\xE1rio: ler o de todas as unidades', false),
535
+ ('clinical.create', 'clinical', 'Prontu\xE1rio: abrir sess\xE3o e registrar consumo', false),
536
+ ('clinical.edit', 'clinical', 'Prontu\xE1rio: corrigir sess\xE3o, consumo e exig\xEAncia', false),
537
+ ('clinical.delete', 'clinical', 'Prontu\xE1rio: apagar', false),
538
+ ('clinical.manage', 'clinical', 'Prontu\xE1rio: administrar', false)
539
+ ON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = EXCLUDED.description;
540
+
541
+ INSERT INTO app.role_permissions (tenant_id, role_id, permission)
542
+ SELECT rp.tenant_id, rp.role_id, replace(rp.permission, 'agenda.', 'clinical.')
543
+ FROM app.role_permissions rp
544
+ WHERE rp.permission IN ('agenda.read', 'agenda.read_all', 'agenda.create',
545
+ 'agenda.edit', 'agenda.delete', 'agenda.manage')
546
+ ON CONFLICT DO NOTHING;
547
+ `;
548
+ var MIGRATION_002_O_PRONTUARIO_DO_V1_ATRAVESSA = `-- O prontu\xE1rio do V1 atravessa.
549
+ --
550
+ -- A 001 fez as cinco tabelas. Esta faz o TRANSPORTE: os writers do motor de
551
+ -- porte e as cinco linhas do de-para. Sem ela as tabelas existem vazias.
552
+ --
553
+ -- journey_executions 233.934 \u2192 plg_clinical_sessions
554
+ -- execution_products 117.011 \u2192 plg_clinical_session_products
555
+ -- execution_requirements 6.590 \u2192 plg_clinical_requirements
556
+ -- service_revisions 110 \u2192 plg_clinical_service_revisions
557
+ -- service_revision_forms 87 \u2192 (jsonb da revis\xE3o)
558
+ -- prepaid_item_outcomes 30 \u2192 plg_clinical_prepaid_outcomes
559
+ --
560
+ -- \u2500\u2500 Duas tabelas do V1 n\xE3o t\xEAm \`uuid\` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
561
+ --
562
+ -- \`execution_requirements\` e \`prepaid_item_outcomes\` n\xE3o guardam uuid nenhum \u2014
563
+ -- s\xF3 o id num\xE9rico. O motor j\xE1 sabe lidar com isso (o id do destino nasce
564
+ -- determin\xEDstico da chave da origem), e \xE9 por isso que o de-para delas n\xE3o
565
+ -- mapeia \`uuid\`.
566
+ --
567
+ -- \u2500\u2500 A refer\xEAncia polim\xF3rfica, resolvida em tempo de escrita \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
568
+ --
569
+ -- \`execution_requirements.resolved_table\` + \`resolved_id\` apontam para uma
570
+ -- tabela QUALQUER, e o \`@tabela\` do de-para \xE9 est\xE1tico. Ent\xE3o quem resolve \xE9 o
571
+ -- writer, lendo o ledger com o nome da tabela que chegou na linha. Medido no
572
+ -- tenant 1:
573
+ --
574
+ -- client_form_responses 2.685 ainda n\xE3o migrou \u2192 fica NULO, com o
575
+ -- n\xFAmero cru em metadata
576
+ -- journey_executions 1.048 resolve
577
+ -- generated_contracts 845 ainda n\xE3o migrou
578
+ -- invoice_items 423 resolve
579
+ -- appointments 80 resolve
580
+ --
581
+ -- O n\xFAmero cru fica em \`metadata.clinical.v1_resolved_ref\` de prop\xF3sito: no dia
582
+ -- em que \`client_form_responses\` atravessar, uma passada de costura preenche os
583
+ -- 2.685 sem reler a origem \u2014 foi o que a 011 da agenda fez com a sala.
584
+ --
585
+ -- \u2500\u2500 A revis\xE3o e os formul\xE1rios dela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
586
+ --
587
+ -- 87 linhas de \`service_revision_forms\` em 110 revis\xF5es. Uma lista curta, sem
588
+ -- tela e sem vida pr\xF3pria, ent\xE3o ela entra como jsonb DENTRO da revis\xE3o \u2014 e o
589
+ -- writer que a escreve \xE9 um facet: acha a revis\xE3o pelo ledger e acrescenta,
590
+ -- sem duplicar quando a onda roda de novo.
591
+ --
592
+ -- Idempotente.
593
+
594
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
595
+ -- 1. O vocabul\xE1rio fechado aprende quatro palavras
596
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
597
+ --
598
+ -- Por ACR\xC9SCIMO, lendo a defini\xE7\xE3o vigente. Lista literal apaga o que um passo
599
+ -- anterior acrescentou \u2014 \xE9 a armadilha registrada em financial/072 e agenda/008.
600
+ DO $$
601
+ DECLARE v_def text;
602
+ BEGIN
603
+ SELECT pg_get_constraintdef(oid) INTO v_def
604
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
605
+ IF v_def IS NULL OR strpos(v_def, 'clinical_session') > 0 THEN RETURN; END IF;
606
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
607
+ EXECUTE replace(
608
+ 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
609
+ '''person''::text',
610
+ '''person''::text, ''clinical_session''::text, ''clinical_session_product''::text, '
611
+ '''clinical_requirement''::text, ''clinical_service_revision''::text, '
612
+ '''clinical_revision_form''::text, ''clinical_prepaid_outcome''::text');
613
+ END $$;
614
+
615
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
616
+ -- 2. Uma ajuda: resolver refer\xEAncia polim\xF3rfica pelo ledger
617
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
618
+ --
619
+ -- SECURITY DEFINER porque l\xEA \`migration.ledger\`, que o papel do porte j\xE1
620
+ -- alcan\xE7a mas nenhuma pol\xEDtica de tenant descreve. Devolve NULO quando a linha
621
+ -- de origem ainda n\xE3o atravessou \u2014 e NULO aqui \xE9 resposta, n\xE3o erro.
622
+ CREATE OR REPLACE FUNCTION migration._clinical_resolve(
623
+ p_tenant uuid, p_table text, p_id text
624
+ ) RETURNS uuid
625
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
626
+ AS $function$
627
+ SELECT l.target_id
628
+ FROM migration.ledger l
629
+ WHERE l.tenant_id = p_tenant
630
+ AND l.source_table = p_table
631
+ AND l.status = 'migrated'
632
+ AND l.source_numeric_id = nullif(p_id, '')::bigint
633
+ LIMIT 1
634
+ $function$;
635
+
636
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
637
+ -- 3. O writer da sess\xE3o
638
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
639
+
640
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_session(
641
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
642
+ ) RETURNS jsonb
643
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
644
+ AS $function$
645
+ DECLARE
646
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
647
+ v_status text := nullif(p_row ->> 'status', '');
648
+ v_unit uuid;
649
+ v_existing uuid;
650
+ v_created boolean := false;
651
+ v_standalone boolean := migration._standalone();
652
+ BEGIN
653
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
654
+ IF p_id IS NULL THEN
655
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a clinical session needs an id');
656
+ END IF;
657
+
658
+ -- O vocabul\xE1rio \xE9 fechado na tabela. Recusar aqui, com a palavra na m\xE3o, \xE9
659
+ -- melhor do que deixar a CHECK derrubar o lote sem dizer qual linha.
660
+ IF v_status IS NULL OR v_status NOT IN ('quoted','contracted','scheduled','in_service','in_progress','executed','pending') THEN
661
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
662
+ 'reason', format('clinical session status %L is not one of the seven the V1 writes', v_status));
663
+ END IF;
664
+
665
+ BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
666
+ -- Sem unidade na linha, a do agendamento serve: \xE9 o mesmo atendimento.
667
+ IF v_unit IS NULL AND (p_row ->> 'booking_id') IS NOT NULL THEN
668
+ SELECT a.unit_id INTO v_unit FROM public.appointments a
669
+ WHERE a.id = (p_row ->> 'booking_id')::uuid AND a.tenant_id = p_tenant;
670
+ END IF;
671
+
672
+ SELECT tenant_id INTO v_existing FROM public.plg_clinical_sessions WHERE id = p_id;
673
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
674
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
675
+ 'reason', format('clinical session %s already exists in another tenant', p_id));
676
+ END IF;
677
+
678
+ IF v_existing IS NULL THEN
679
+ INSERT INTO public.plg_clinical_sessions
680
+ (id, tenant_id, unit_id, status, booking_id, booking_item_id, party_id,
681
+ assignee_id, performed_by_id, service_id, order_item_id, quote_item_id, deal_id,
682
+ revision_number, parent_session_id, source_evaluation_id,
683
+ scheduled_for, started_at, ended_at, products_initialized, notes,
684
+ metadata, created_at, updated_at)
685
+ VALUES
686
+ (p_id, p_tenant, v_unit, v_status,
687
+ nullif(p_row ->> 'booking_id', '')::uuid,
688
+ nullif(p_row ->> 'booking_item_id', '')::uuid,
689
+ nullif(p_row ->> 'party_id', '')::uuid,
690
+ nullif(p_row ->> 'assignee_id', '')::uuid,
691
+ nullif(p_row ->> 'performed_by_id', '')::uuid,
692
+ nullif(p_row ->> 'service_id', '')::uuid,
693
+ nullif(p_row ->> 'order_item_id', '')::uuid,
694
+ nullif(p_row ->> 'quote_item_id', '')::uuid,
695
+ nullif(p_row ->> 'deal_id', '')::uuid,
696
+ greatest(coalesce(nullif(p_row ->> 'revision_number', '')::integer, 1), 1),
697
+ nullif(p_row ->> 'parent_session_id', '')::uuid,
698
+ nullif(p_row ->> 'source_evaluation_id', '')::uuid,
699
+ nullif(p_row ->> 'scheduled_for', '')::date,
700
+ nullif(p_row ->> 'started_at', '')::timestamptz,
701
+ nullif(p_row ->> 'ended_at', '')::timestamptz,
702
+ coalesce((p_row ->> 'products_initialized')::boolean, false),
703
+ p_row ->> 'notes', v_meta,
704
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
705
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
706
+ v_created := true;
707
+ ELSE
708
+ UPDATE public.plg_clinical_sessions SET
709
+ unit_id = coalesce(v_unit, unit_id),
710
+ status = v_status,
711
+ booking_id = coalesce(nullif(p_row ->> 'booking_id', '')::uuid, booking_id),
712
+ booking_item_id = coalesce(nullif(p_row ->> 'booking_item_id', '')::uuid, booking_item_id),
713
+ party_id = coalesce(nullif(p_row ->> 'party_id', '')::uuid, party_id),
714
+ assignee_id = coalesce(nullif(p_row ->> 'assignee_id', '')::uuid, assignee_id),
715
+ performed_by_id = coalesce(nullif(p_row ->> 'performed_by_id', '')::uuid, performed_by_id),
716
+ service_id = coalesce(nullif(p_row ->> 'service_id', '')::uuid, service_id),
717
+ order_item_id = coalesce(nullif(p_row ->> 'order_item_id', '')::uuid, order_item_id),
718
+ quote_item_id = coalesce(nullif(p_row ->> 'quote_item_id', '')::uuid, quote_item_id),
719
+ deal_id = coalesce(nullif(p_row ->> 'deal_id', '')::uuid, deal_id),
720
+ parent_session_id = coalesce(nullif(p_row ->> 'parent_session_id', '')::uuid, parent_session_id),
721
+ source_evaluation_id = coalesce(nullif(p_row ->> 'source_evaluation_id', '')::uuid, source_evaluation_id),
722
+ scheduled_for = nullif(p_row ->> 'scheduled_for', '')::date,
723
+ started_at = nullif(p_row ->> 'started_at', '')::timestamptz,
724
+ ended_at = nullif(p_row ->> 'ended_at', '')::timestamptz,
725
+ products_initialized = coalesce((p_row ->> 'products_initialized')::boolean, products_initialized),
726
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
727
+ metadata = migration._merge_meta(metadata, v_meta),
728
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
729
+ WHERE id = p_id;
730
+ END IF;
731
+
732
+ PERFORM migration._set_unit('public.plg_clinical_sessions', p_id, v_unit);
733
+ IF v_standalone THEN
734
+ PERFORM migration._audit(p_tenant, 'public.plg_clinical_sessions', p_id,
735
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
736
+ END IF;
737
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
738
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
739
+ END $function$;
740
+
741
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
742
+ -- 4. O writer do produto consumido
743
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
744
+
745
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_session_product(
746
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
747
+ ) RETURNS jsonb
748
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
749
+ AS $function$
750
+ DECLARE
751
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
752
+ v_session uuid;
753
+ v_product uuid;
754
+ v_unit uuid;
755
+ v_existing uuid;
756
+ v_created boolean := false;
757
+ v_standalone boolean := migration._standalone();
758
+ BEGIN
759
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
760
+ IF p_id IS NULL THEN
761
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a session product needs an id');
762
+ END IF;
763
+
764
+ BEGIN v_session := (p_row ->> 'session_id')::uuid; EXCEPTION WHEN OTHERS THEN v_session := NULL; END;
765
+ BEGIN v_product := (p_row ->> 'product_id')::uuid; EXCEPTION WHEN OTHERS THEN v_product := NULL; END;
766
+ -- Consumo sem sess\xE3o \xE9 consumo que nenhuma tela alcan\xE7a; sem produto \xE9 uma
767
+ -- quantidade de nada. Os dois s\xE3o recusa de FK, e a 2\xAA passada do motor os
768
+ -- reapresenta depois que a sess\xE3o migrar.
769
+ IF v_session IS NULL THEN
770
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
771
+ 'reason', 'a session product needs the session it was used in');
772
+ END IF;
773
+ IF v_product IS NULL THEN
774
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
775
+ 'reason', 'a session product needs the product that was used');
776
+ END IF;
777
+
778
+ SELECT s.unit_id INTO v_unit FROM public.plg_clinical_sessions s
779
+ WHERE s.id = v_session AND s.tenant_id = p_tenant;
780
+
781
+ SELECT tenant_id INTO v_existing FROM public.plg_clinical_session_products WHERE id = p_id;
782
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
783
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
784
+ 'reason', format('session product %s already exists in another tenant', p_id));
785
+ END IF;
786
+
787
+ IF v_existing IS NULL THEN
788
+ INSERT INTO public.plg_clinical_session_products
789
+ (id, tenant_id, unit_id, session_id, product_id, order_item_id, assignee_id,
790
+ default_quantity, used_quantity, used_unit_id, executed_on,
791
+ is_audited, audited_at, audit_notes, metadata, created_at, updated_at)
792
+ VALUES
793
+ (p_id, p_tenant, v_unit, v_session, v_product,
794
+ nullif(p_row ->> 'order_item_id', '')::uuid,
795
+ nullif(p_row ->> 'assignee_id', '')::uuid,
796
+ nullif(p_row ->> 'default_quantity', '')::numeric,
797
+ nullif(p_row ->> 'used_quantity', '')::numeric,
798
+ nullif(p_row ->> 'used_unit_id', '')::uuid,
799
+ nullif(p_row ->> 'executed_on', '')::date,
800
+ coalesce((p_row ->> 'is_audited')::boolean, false),
801
+ nullif(p_row ->> 'audited_at', '')::timestamptz,
802
+ p_row ->> 'audit_notes', v_meta,
803
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
804
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
805
+ v_created := true;
806
+ ELSE
807
+ UPDATE public.plg_clinical_session_products SET
808
+ unit_id = coalesce(v_unit, unit_id),
809
+ session_id = v_session,
810
+ product_id = v_product,
811
+ order_item_id = coalesce(nullif(p_row ->> 'order_item_id', '')::uuid, order_item_id),
812
+ assignee_id = coalesce(nullif(p_row ->> 'assignee_id', '')::uuid, assignee_id),
813
+ default_quantity = nullif(p_row ->> 'default_quantity', '')::numeric,
814
+ used_quantity = nullif(p_row ->> 'used_quantity', '')::numeric,
815
+ used_unit_id = coalesce(nullif(p_row ->> 'used_unit_id', '')::uuid, used_unit_id),
816
+ executed_on = nullif(p_row ->> 'executed_on', '')::date,
817
+ is_audited = coalesce((p_row ->> 'is_audited')::boolean, is_audited),
818
+ audited_at = nullif(p_row ->> 'audited_at', '')::timestamptz,
819
+ audit_notes = CASE WHEN p_row ? 'audit_notes' THEN p_row ->> 'audit_notes' ELSE audit_notes END,
820
+ metadata = migration._merge_meta(metadata, v_meta),
821
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
822
+ WHERE id = p_id;
823
+ END IF;
824
+
825
+ IF v_standalone THEN
826
+ PERFORM migration._audit(p_tenant, 'public.plg_clinical_session_products', p_id,
827
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
828
+ END IF;
829
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
830
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
831
+ END $function$;
832
+
833
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
834
+ -- 5. O writer da exig\xEAncia
835
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
836
+
837
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_requirement(
838
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
839
+ ) RETURNS jsonb
840
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
841
+ AS $function$
842
+ DECLARE
843
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
844
+ v_kind text := nullif(p_row ->> 'kind', '');
845
+ v_scope text := nullif(p_row ->> 'scope', '');
846
+ v_status text := coalesce(nullif(p_row ->> 'status', ''), 'pending');
847
+ v_session uuid;
848
+ v_unit uuid;
849
+ v_subj_t text := nullif(p_row ->> 'subject_type', '');
850
+ v_cand_t text := nullif(p_row ->> 'candidate_type', '');
851
+ v_subj_id uuid;
852
+ v_cand_id uuid;
853
+ v_existing uuid;
854
+ v_created boolean := false;
855
+ v_standalone boolean := migration._standalone();
856
+ BEGIN
857
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
858
+ IF p_id IS NULL THEN
859
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a requirement needs an id');
860
+ END IF;
861
+ IF v_kind IS NULL OR v_kind NOT IN ('form','evaluation','contract','session_term','products','invoice_item','revision_link') THEN
862
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
863
+ 'reason', format('requirement kind %L is not one of the seven the V1 writes', v_kind));
864
+ END IF;
865
+ IF v_scope IS NULL OR v_scope NOT IN ('service','attendance') THEN
866
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
867
+ 'reason', format('requirement scope %L is neither service nor attendance', v_scope));
868
+ END IF;
869
+ IF v_status NOT IN ('pending','resolved','not_applicable') THEN
870
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
871
+ 'reason', format('requirement status %L is not a status the V1 writes', v_status));
872
+ END IF;
873
+
874
+ BEGIN v_session := (p_row ->> 'session_id')::uuid; EXCEPTION WHEN OTHERS THEN v_session := NULL; END;
875
+ IF v_session IS NOT NULL THEN
876
+ SELECT s.unit_id INTO v_unit FROM public.plg_clinical_sessions s
877
+ WHERE s.id = v_session AND s.tenant_id = p_tenant;
878
+ END IF;
879
+
880
+ -- A refer\xEAncia polim\xF3rfica: o de-para n\xE3o alcan\xE7a, o ledger sim. Quando a
881
+ -- tabela apontada ainda n\xE3o atravessou o resultado \xE9 NULO \u2014 e o n\xFAmero cru
882
+ -- fica no metadata para a costura de depois.
883
+ v_subj_id := migration._clinical_resolve(p_tenant, v_subj_t, p_row ->> 'v1_resolved_ref');
884
+ v_cand_id := migration._clinical_resolve(p_tenant, v_cand_t, p_row ->> 'v1_candidate_ref');
885
+
886
+ SELECT tenant_id INTO v_existing FROM public.plg_clinical_requirements WHERE id = p_id;
887
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
888
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
889
+ 'reason', format('requirement %s already exists in another tenant', p_id));
890
+ END IF;
891
+
892
+ IF v_existing IS NULL THEN
893
+ INSERT INTO public.plg_clinical_requirements
894
+ (id, tenant_id, unit_id, session_id, booking_id, party_id, service_id,
895
+ kind, scope, blocks_start, blocks_finish, status,
896
+ subject_type, subject_id, candidate_type, candidate_id,
897
+ resolved_via, resolved_at, valid_until, frozen_at, rule_sources,
898
+ metadata, created_at, updated_at)
899
+ VALUES
900
+ (p_id, p_tenant, v_unit, v_session,
901
+ nullif(p_row ->> 'booking_id', '')::uuid,
902
+ nullif(p_row ->> 'party_id', '')::uuid,
903
+ nullif(p_row ->> 'service_id', '')::uuid,
904
+ v_kind, v_scope,
905
+ coalesce((p_row ->> 'blocks_start')::boolean, false),
906
+ coalesce((p_row ->> 'blocks_finish')::boolean, false),
907
+ v_status, v_subj_t, v_subj_id, v_cand_t, v_cand_id,
908
+ nullif(p_row ->> 'resolved_via', ''),
909
+ nullif(p_row ->> 'resolved_at', '')::timestamptz,
910
+ nullif(p_row ->> 'valid_until', '')::timestamptz,
911
+ nullif(p_row ->> 'frozen_at', '')::timestamptz,
912
+ coalesce(p_row -> 'rule_sources', '[]'::jsonb),
913
+ v_meta,
914
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
915
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
916
+ v_created := true;
917
+ ELSE
918
+ UPDATE public.plg_clinical_requirements SET
919
+ unit_id = coalesce(v_unit, unit_id),
920
+ session_id = coalesce(v_session, session_id),
921
+ booking_id = coalesce(nullif(p_row ->> 'booking_id', '')::uuid, booking_id),
922
+ party_id = coalesce(nullif(p_row ->> 'party_id', '')::uuid, party_id),
923
+ service_id = coalesce(nullif(p_row ->> 'service_id', '')::uuid, service_id),
924
+ kind = v_kind,
925
+ scope = v_scope,
926
+ blocks_start = coalesce((p_row ->> 'blocks_start')::boolean, blocks_start),
927
+ blocks_finish = coalesce((p_row ->> 'blocks_finish')::boolean, blocks_finish),
928
+ status = v_status,
929
+ subject_type = v_subj_t,
930
+ -- \`coalesce\` e n\xE3o substitui\xE7\xE3o: a segunda onda resolve o que a primeira
931
+ -- n\xE3o alcan\xE7ou, e nunca apaga o que j\xE1 estava resolvido.
932
+ subject_id = coalesce(v_subj_id, subject_id),
933
+ candidate_type = v_cand_t,
934
+ candidate_id = coalesce(v_cand_id, candidate_id),
935
+ resolved_via = nullif(p_row ->> 'resolved_via', ''),
936
+ resolved_at = nullif(p_row ->> 'resolved_at', '')::timestamptz,
937
+ valid_until = nullif(p_row ->> 'valid_until', '')::timestamptz,
938
+ frozen_at = nullif(p_row ->> 'frozen_at', '')::timestamptz,
939
+ rule_sources = coalesce(p_row -> 'rule_sources', rule_sources),
940
+ metadata = migration._merge_meta(metadata, v_meta),
941
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
942
+ WHERE id = p_id;
943
+ END IF;
944
+
945
+ IF v_standalone THEN
946
+ PERFORM migration._audit(p_tenant, 'public.plg_clinical_requirements', p_id,
947
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
948
+ END IF;
949
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
950
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
951
+ END $function$;
952
+
953
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
954
+ -- 6. A revis\xE3o do servi\xE7o, e os formul\xE1rios dela
955
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
956
+
957
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_service_revision(
958
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
959
+ ) RETURNS jsonb
960
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
961
+ AS $function$
962
+ DECLARE
963
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
964
+ v_service uuid;
965
+ v_number integer;
966
+ v_existing uuid;
967
+ v_created boolean := false;
968
+ v_standalone boolean := migration._standalone();
969
+ BEGIN
970
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
971
+ IF p_id IS NULL THEN
972
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a service revision needs an id');
973
+ END IF;
974
+
975
+ BEGIN v_service := (p_row ->> 'service_id')::uuid; EXCEPTION WHEN OTHERS THEN v_service := NULL; END;
976
+ IF v_service IS NULL THEN
977
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
978
+ 'reason', 'a service revision needs the service it freezes');
979
+ END IF;
980
+ v_number := greatest(coalesce(nullif(p_row ->> 'revision_number', '')::integer, 1), 1);
981
+
982
+ SELECT tenant_id INTO v_existing FROM public.plg_clinical_service_revisions WHERE id = p_id;
983
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
984
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
985
+ 'reason', format('service revision %s already exists in another tenant', p_id));
986
+ END IF;
987
+
988
+ IF v_existing IS NULL THEN
989
+ INSERT INTO public.plg_clinical_service_revisions
990
+ (id, tenant_id, service_id, revision_number, price, duration_minutes, deadline_days,
991
+ metadata, created_at, updated_at)
992
+ VALUES
993
+ (p_id, p_tenant, v_service, v_number,
994
+ nullif(p_row ->> 'price', '')::numeric,
995
+ nullif(p_row ->> 'duration_minutes', '')::integer,
996
+ nullif(p_row ->> 'deadline_days', '')::integer,
997
+ v_meta,
998
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
999
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
1000
+ v_created := true;
1001
+ ELSE
1002
+ UPDATE public.plg_clinical_service_revisions SET
1003
+ service_id = v_service,
1004
+ revision_number = v_number,
1005
+ price = nullif(p_row ->> 'price', '')::numeric,
1006
+ duration_minutes = nullif(p_row ->> 'duration_minutes', '')::integer,
1007
+ deadline_days = nullif(p_row ->> 'deadline_days', '')::integer,
1008
+ metadata = migration._merge_meta(metadata, v_meta),
1009
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
1010
+ WHERE id = p_id;
1011
+ END IF;
1012
+
1013
+ IF v_standalone THEN
1014
+ PERFORM migration._audit(p_tenant, 'public.plg_clinical_service_revisions', p_id,
1015
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
1016
+ END IF;
1017
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1018
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
1019
+ END $function$;
1020
+
1021
+ -- O facet: acrescenta um formul\xE1rio ao jsonb da revis\xE3o. A chave de dedupe \xE9 o
1022
+ -- id da linha do V1, e \xE9 isso que faz a onda rodar duas vezes sem duplicar.
1023
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_revision_form(
1024
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1025
+ ) RETURNS jsonb
1026
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1027
+ AS $function$
1028
+ DECLARE
1029
+ v_rev uuid;
1030
+ v_ref text := p_row ->> 'v1_ref';
1031
+ v_entry jsonb;
1032
+ v_standalone boolean := migration._standalone();
1033
+ BEGIN
1034
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1035
+
1036
+ BEGIN v_rev := (p_row ->> 'revision_id')::uuid; EXCEPTION WHEN OTHERS THEN v_rev := NULL; END;
1037
+ IF v_rev IS NULL THEN
1038
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1039
+ 'reason', 'a revision form needs the revision it belongs to');
1040
+ END IF;
1041
+ IF NOT EXISTS (SELECT 1 FROM public.plg_clinical_service_revisions r
1042
+ WHERE r.id = v_rev AND r.tenant_id = p_tenant) THEN
1043
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1044
+ 'reason', 'the revision this form belongs to has not arrived yet');
1045
+ END IF;
1046
+
1047
+ v_entry := jsonb_build_object(
1048
+ 'v1_ref', v_ref,
1049
+ 'v1_form_template_ref', p_row ->> 'v1_form_template_ref');
1050
+
1051
+ UPDATE public.plg_clinical_service_revisions
1052
+ SET form_template_refs = (
1053
+ SELECT coalesce(jsonb_agg(e), '[]'::jsonb)
1054
+ FROM (SELECT e FROM jsonb_array_elements(form_template_refs) e
1055
+ WHERE e ->> 'v1_ref' IS DISTINCT FROM v_ref
1056
+ UNION ALL SELECT v_entry) s(e)),
1057
+ updated_at = now()
1058
+ WHERE id = v_rev;
1059
+
1060
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_rev, 'reason', 'attached');
1061
+ END $function$;
1062
+
1063
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1064
+ -- 7. O writer do desfecho
1065
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1066
+
1067
+ CREATE OR REPLACE FUNCTION migration.upsert_clinical_prepaid_outcome(
1068
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1069
+ ) RETURNS jsonb
1070
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1071
+ AS $function$
1072
+ DECLARE
1073
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1074
+ v_outcome text := nullif(p_row ->> 'outcome', '');
1075
+ v_booking uuid;
1076
+ v_unit uuid;
1077
+ v_existing uuid;
1078
+ v_created boolean := false;
1079
+ v_standalone boolean := migration._standalone();
1080
+ BEGIN
1081
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1082
+ IF p_id IS NULL THEN
1083
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an outcome needs an id');
1084
+ END IF;
1085
+ IF v_outcome IS NULL OR v_outcome NOT IN ('contracted','declined','postponed') THEN
1086
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1087
+ 'reason', format('outcome %L is not one of the three the V1 writes', v_outcome));
1088
+ END IF;
1089
+
1090
+ BEGIN v_booking := (p_row ->> 'booking_id')::uuid; EXCEPTION WHEN OTHERS THEN v_booking := NULL; END;
1091
+ IF v_booking IS NOT NULL THEN
1092
+ SELECT a.unit_id INTO v_unit FROM public.appointments a
1093
+ WHERE a.id = v_booking AND a.tenant_id = p_tenant;
1094
+ END IF;
1095
+
1096
+ SELECT tenant_id INTO v_existing FROM public.plg_clinical_prepaid_outcomes WHERE id = p_id;
1097
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
1098
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
1099
+ 'reason', format('outcome %s already exists in another tenant', p_id));
1100
+ END IF;
1101
+
1102
+ IF v_existing IS NULL THEN
1103
+ INSERT INTO public.plg_clinical_prepaid_outcomes
1104
+ (id, tenant_id, unit_id, booking_id, order_item_id, outcome, reason, decided_at, metadata, created_at, updated_at)
1105
+ VALUES
1106
+ (p_id, p_tenant, v_unit, v_booking,
1107
+ nullif(p_row ->> 'order_item_id', '')::uuid,
1108
+ v_outcome, p_row ->> 'reason',
1109
+ coalesce(nullif(p_row ->> 'decided_at', '')::timestamptz, now()),
1110
+ v_meta, now(), now());
1111
+ v_created := true;
1112
+ ELSE
1113
+ UPDATE public.plg_clinical_prepaid_outcomes SET
1114
+ unit_id = coalesce(v_unit, unit_id),
1115
+ booking_id = coalesce(v_booking, booking_id),
1116
+ order_item_id = coalesce(nullif(p_row ->> 'order_item_id', '')::uuid, order_item_id),
1117
+ outcome = v_outcome,
1118
+ reason = CASE WHEN p_row ? 'reason' THEN p_row ->> 'reason' ELSE reason END,
1119
+ decided_at = coalesce(nullif(p_row ->> 'decided_at', '')::timestamptz, decided_at),
1120
+ metadata = migration._merge_meta(metadata, v_meta),
1121
+ updated_at = now()
1122
+ WHERE id = p_id;
1123
+ END IF;
1124
+
1125
+ IF v_standalone THEN
1126
+ PERFORM migration._audit(p_tenant, 'public.plg_clinical_prepaid_outcomes', p_id,
1127
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
1128
+ END IF;
1129
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1130
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
1131
+ END $function$;
1132
+
1133
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1134
+ -- 8. As seis linhas do de-para
1135
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1136
+
1137
+ SET LOCAL migration.allowlist_unlock = 'on';
1138
+
1139
+ SELECT migration._allow(
1140
+ 'service_revisions', 'public.plg_clinical_service_revisions', 'clinical', 'clinical_service_revision',
1141
+ '{}'::jsonb, NULL::text[],
1142
+ jsonb_build_object(
1143
+ 'uuid', 'id',
1144
+ 'service_id', 'service_id@services',
1145
+ 'revision_number', 'revision_number',
1146
+ 'price', 'price',
1147
+ 'duration_minutes', 'duration_minutes',
1148
+ 'deadline_days', 'deadline_days',
1149
+ 'created_at', 'created_at',
1150
+ 'tenant_id', '-',
1151
+ 'id', 'jsonb:clinical.v1_ref'),
1152
+ 'A vers\xE3o congelada do servi\xE7o. Vem ANTES da sess\xE3o porque \xE9 ela que d\xE1 o '
1153
+ 'pre\xE7o e o prazo que valiam no dia da contrata\xE7\xE3o.'
1154
+ );
1155
+
1156
+ SELECT migration._allow(
1157
+ 'service_revision_forms', 'public.plg_clinical_service_revisions', 'clinical', 'clinical_revision_form',
1158
+ '{}'::jsonb, NULL::text[],
1159
+ jsonb_build_object(
1160
+ 'service_revision_id', 'revision_id@service_revisions',
1161
+ 'form_template_id', 'v1_form_template_ref',
1162
+ 'id', 'v1_ref',
1163
+ 'uuid', '-',
1164
+ 'service_id', '-',
1165
+ 'created_at', '-',
1166
+ 'tenant_id', '-'),
1167
+ '87 linhas em 110 revis\xF5es. N\xE3o tem tabela pr\xF3pria: entra no jsonb '
1168
+ '\`form_template_refs\` da revis\xE3o, e o writer \xE9 um facet que dedupe pelo id do '
1169
+ 'V1 \u2014 rodar a onda duas vezes n\xE3o duplica.'
1170
+ );
1171
+
1172
+ SELECT migration._allow(
1173
+ 'journey_executions', 'public.plg_clinical_sessions', 'clinical', 'clinical_session',
1174
+ '{}'::jsonb, NULL::text[],
1175
+ jsonb_build_object(
1176
+ 'uuid', 'id',
1177
+ 'journey_status', 'status',
1178
+ 'appointment_id', 'booking_id@appointments',
1179
+ 'appointment_service_id', 'booking_item_id@appointment_services',
1180
+ 'client_id', 'party_id@clients',
1181
+ 'professional_id', 'assignee_id@professionals',
1182
+ 'execution_professional_id', 'performed_by_id@professionals',
1183
+ 'service_id', 'service_id@services',
1184
+ 'invoice_item_id', 'order_item_id@invoice_items',
1185
+ 'quote_item_id', 'quote_item_id@quote_items',
1186
+ 'opportunity_id', 'deal_id@opportunities',
1187
+ 'parent_execution_id', 'parent_session_id@journey_executions',
1188
+ 'source_evaluation_execution_id', 'source_evaluation_id@journey_executions',
1189
+ 'unit_numeric_id', 'unit_id@companies',
1190
+ 'revision_number', 'revision_number',
1191
+ 'appointment_date', 'scheduled_for',
1192
+ 'execution_start_time', 'started_at',
1193
+ 'execution_end_time', 'ended_at',
1194
+ 'products_initialized', 'products_initialized',
1195
+ 'notes', 'notes',
1196
+ 'created_at', 'created_at',
1197
+ 'updated_at', 'updated_at',
1198
+ 'tenant_id', '-',
1199
+ 'product_id', '-',
1200
+ 'status', 'jsonb:clinical.v1_status',
1201
+ 'execution_photos', 'jsonb:clinical.v1_execution_photos',
1202
+ 'id', 'jsonb:clinical.v1_ref'),
1203
+ 'A sess\xE3o de execu\xE7\xE3o. \`journey_status\` \xE9 quem manda no estado \u2014 tem sete '
1204
+ 'palavras contra as quatro de \`status\`, e \xE9 a \xFAnica que separa or\xE7ado de '
1205
+ 'contratado; a outra fica em metadata. \`product_id\` sai: 0 de 233.950 '
1206
+ 'preenchidas. As fotos ficam em metadata at\xE9 virarem linhas de '
1207
+ 'public.documents, que \xE9 onde arquivo mora.'
1208
+ );
1209
+
1210
+ SELECT migration._allow(
1211
+ 'execution_products', 'public.plg_clinical_session_products', 'clinical', 'clinical_session_product',
1212
+ '{}'::jsonb, NULL::text[],
1213
+ jsonb_build_object(
1214
+ 'uuid', 'id',
1215
+ 'journey_execution_id', 'session_id@journey_executions',
1216
+ 'product_id', 'product_id@products',
1217
+ 'invoice_item_id', 'order_item_id@invoice_items',
1218
+ 'professional_id', 'assignee_id@professionals',
1219
+ 'used_unit_id', 'used_unit_id@measurement_units',
1220
+ 'unit_id', '-',
1221
+ 'default_quantity', 'default_quantity',
1222
+ 'used_quantity', 'used_quantity',
1223
+ 'execution_date', 'executed_on',
1224
+ 'is_audited', 'is_audited',
1225
+ 'audited_at', 'audited_at',
1226
+ 'audit_notes', 'audit_notes',
1227
+ 'audited_by_user_id', '-',
1228
+ 'created_at', 'created_at',
1229
+ 'updated_at', 'updated_at',
1230
+ 'tenant_id', '-',
1231
+ 'stock_deducted', '-',
1232
+ 'stock_movement_id', '-',
1233
+ 'stock_position_id', '-',
1234
+ 'position_allocations', '-',
1235
+ 'unit_label_allocations', '-',
1236
+ 'consumption_photos', 'jsonb:clinical.v1_consumption_photos',
1237
+ 'id', 'jsonb:clinical.v1_ref'),
1238
+ 'O produto consumido. As cinco colunas de baixa de estoque saem porque est\xE3o '
1239
+ 'VAZIAS: 0 de 117.015 no tenant 1. O consumo do V1 nunca chegou ao raz\xE3o \u2014 \xE9 '
1240
+ 'o que o Guia descreve na jornada auditoria-de-uso-e-estoque. A unidade vem '
1241
+ 'da sess\xE3o, no writer, e n\xE3o da coluna \`unit_id\` da origem.'
1242
+ );
1243
+
1244
+ SELECT migration._allow(
1245
+ 'execution_requirements', 'public.plg_clinical_requirements', 'clinical', 'clinical_requirement',
1246
+ '{}'::jsonb, NULL::text[],
1247
+ jsonb_build_object(
1248
+ 'journey_execution_id', 'session_id@journey_executions',
1249
+ 'appointment_id', 'booking_id@appointments',
1250
+ 'client_id', 'party_id@clients',
1251
+ 'service_id', 'service_id@services',
1252
+ 'kind', 'kind',
1253
+ 'scope', 'scope',
1254
+ 'status', 'status',
1255
+ 'blocks_start', 'blocks_start',
1256
+ 'blocks_finish', 'blocks_finish',
1257
+ 'resolved_table', 'subject_type',
1258
+ 'resolved_id', 'v1_resolved_ref',
1259
+ 'candidate_table', 'candidate_type',
1260
+ 'candidate_id', 'v1_candidate_ref',
1261
+ 'resolved_via', 'resolved_via',
1262
+ 'resolved_at', 'resolved_at',
1263
+ 'valid_until', 'valid_until',
1264
+ 'frozen_at', 'frozen_at',
1265
+ 'rule_sources', 'rule_sources',
1266
+ 'created_at', 'created_at',
1267
+ 'updated_at', 'updated_at',
1268
+ 'tenant_id', '-',
1269
+ 'ref_id', 'jsonb:clinical.v1_ref_id',
1270
+ 'id', 'jsonb:clinical.v1_ref'),
1271
+ 'O que trava come\xE7ar e terminar. Sem \`uuid\` na origem \u2014 o id do destino nasce '
1272
+ 'determin\xEDstico da chave num\xE9rica. \`resolved_table\`/\`resolved_id\` \xE9 refer\xEAncia '
1273
+ 'polim\xF3rfica e o \`@tabela\` do de-para \xE9 est\xE1tico, ent\xE3o quem resolve \xE9 o '
1274
+ 'writer, lendo o ledger com o nome que chegou na linha.'
1275
+ );
1276
+
1277
+ SELECT migration._allow(
1278
+ 'prepaid_item_outcomes', 'public.plg_clinical_prepaid_outcomes', 'clinical', 'clinical_prepaid_outcome',
1279
+ '{}'::jsonb, NULL::text[],
1280
+ jsonb_build_object(
1281
+ 'appointment_id', 'booking_id@appointments',
1282
+ 'invoice_item_id', 'order_item_id@invoice_items',
1283
+ 'outcome', 'outcome',
1284
+ 'reason', 'reason',
1285
+ 'decided_at', 'decided_at',
1286
+ 'decided_by', '-',
1287
+ 'tenant_id', '-',
1288
+ 'id', 'jsonb:clinical.v1_ref'),
1289
+ 'O desfecho do servi\xE7o contratado e n\xE3o executado no dia. \`decided_by\` \xE9 '
1290
+ 'usu\xE1rio do auth do V1, que n\xE3o atravessa \u2014 fica de fora em vez de virar uma '
1291
+ 'FK que aponta para nada.'
1292
+ );
1293
+
1294
+ RESET migration.allowlist_unlock;
1295
+
1296
+ REVOKE ALL ON FUNCTION migration._clinical_resolve(uuid, text, text) FROM PUBLIC, anon, authenticated;
1297
+ GRANT EXECUTE ON FUNCTION migration._clinical_resolve(uuid, text, text) TO service_role;
1298
+ DO $$
1299
+ DECLARE f text;
1300
+ BEGIN
1301
+ FOREACH f IN ARRAY ARRAY['upsert_clinical_session', 'upsert_clinical_session_product',
1302
+ 'upsert_clinical_requirement', 'upsert_clinical_service_revision',
1303
+ 'upsert_clinical_revision_form', 'upsert_clinical_prepaid_outcome'] LOOP
1304
+ EXECUTE format('REVOKE ALL ON FUNCTION migration.%I(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated', f);
1305
+ EXECUTE format('GRANT EXECUTE ON FUNCTION migration.%I(uuid, uuid, jsonb, jsonb) TO service_role', f);
1306
+ END LOOP;
1307
+ END $$;
1308
+ `;
1309
+ var MIGRATION_003_A_PERMISSAO_SE_PERGUNTA_POR_UNIDADE_NAO_POR_LINHA = `-- A permiss\xE3o se pergunta por unidade, n\xE3o por linha.
1310
+ --
1311
+ -- A tela \`Estoque \u2192 Auditoria de uso\` estourava o \`statement_timeout\`. Medido
1312
+ -- no cluster, como \`authenticated\`, tenant Espa\xE7o Facial:
1313
+ --
1314
+ -- select * from v_inventory_usage_audit order by used_at desc limit 50
1315
+ -- \u2192 97.143 ms
1316
+ --
1317
+ -- A vista n\xE3o era a culpada. As duas tabelas que ela l\xEA j\xE1 eram lentas sozinhas:
1318
+ --
1319
+ -- select count(*) from plg_clinical_session_products 19.000 ms
1320
+ -- select count(*) from plg_clinical_sessions 22.500 ms
1321
+ --
1322
+ -- \u2500\u2500 A linha que custava \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1323
+ --
1324
+ -- A pol\xEDtica que a 001 escreveu chama \`app.has_permission('clinical.read',
1325
+ -- unit_id)\` \u2014 e \`unit_id\` \xE9 COLUNA, ent\xE3o a chamada \xE9 por LINHA. S\xE3o 116.911
1326
+ -- produtos consumidos e 233.556 sess\xF5es; o Espa\xE7o Facial tem 67 unidades.
1327
+ --
1328
+ -- \xC9 a mesma forma do defeito que o spine 121 corrigiu em \`people\`, e a mesma
1329
+ -- corre\xE7\xE3o: a pergunta n\xE3o muda dentro da unidade, ent\xE3o ela se faz uma vez
1330
+ -- POR UNIDADE. \`unit_id IN (subconsulta n\xE3o correlacionada)\` \xE9 resolvido pelo
1331
+ -- planejador como hash montado uma vez; a subconsulta percorre \`app.units\` do
1332
+ -- inquilino e aplica ali os dois predicados que antes rodavam por linha.
1333
+ --
1334
+ -- A reescrita \xE9 EQUIVALENTE por constru\xE7\xE3o, n\xE3o por argumento: s\xE3o os mesmos
1335
+ -- dois predicados (\`app.has_unit\` e \`app.has_permission\`) sobre os mesmos
1336
+ -- valores de \`unit_id\`, s\xF3 que avaliados uma vez por valor distinto em vez de
1337
+ -- uma vez por linha. A linha sem unidade continua decidida pela permiss\xE3o de
1338
+ -- inquilino, como antes.
1339
+ --
1340
+ -- Medido depois, com ROLLBACK, antes de escrever este arquivo:
1341
+ --
1342
+ -- plg_clinical_session_products 116.911 linhas 450 ms (era 19.000)
1343
+ -- plg_clinical_sessions 233.556 linhas 1.070 ms (era 22.500)
1344
+ -- auditoria de uso, 50 linhas 50 linhas 1.330 ms (era 97.143)
1345
+ --
1346
+ -- As contagens s\xE3o ID\xCANTICAS \xE0s de antes \u2014 \xE9 o teste que importa numa mudan\xE7a
1347
+ -- de pol\xEDtica, porque uma que fica r\xE1pida devolvendo menos linhas n\xE3o ficou
1348
+ -- r\xE1pida, ficou errada.
1349
+ --
1350
+ -- Idempotente.
1351
+
1352
+ DO $$
1353
+ DECLARE
1354
+ t text;
1355
+ v_unidade text := $q$unit_id IN (SELECT u.id FROM app.units u
1356
+ WHERE u.tenant_id = (SELECT app.current_tenant_id())
1357
+ AND app.has_unit(u.id)
1358
+ AND app.has_permission('clinical.read', u.id))$q$;
1359
+ BEGIN
1360
+ FOREACH t IN ARRAY ARRAY['plg_clinical_sessions', 'plg_clinical_session_products',
1361
+ 'plg_clinical_requirements', 'plg_clinical_prepaid_outcomes'] LOOP
1362
+ IF to_regclass('public.' || t) IS NULL THEN CONTINUE; END IF;
1363
+ EXECUTE format($f$ALTER POLICY %I ON public.%I USING (
1364
+ tenant_id = (SELECT app.current_tenant_id())
1365
+ AND (CASE WHEN unit_id IS NULL
1366
+ THEN (SELECT app.has_permission('clinical.read', NULL::uuid))
1367
+ ELSE %s END))$f$, t || '_authz_select', t, v_unidade);
1368
+ END LOOP;
1369
+ END $$;
1370
+
1371
+ -- \`plg_clinical_service_revisions\` fica como est\xE1: ela n\xE3o tem unidade \u2014 a
1372
+ -- revis\xE3o \xE9 do CAT\xC1LOGO, e o pre\xE7o congelado de um servi\xE7o vale na rede
1373
+ -- inteira. A pol\xEDtica dela j\xE1 decide uma vez por consulta.
1374
+
1375
+ -- A auditoria de uso ordena por data e corta em 50. Sem \xEDndice, isso \xE9 ordenar
1376
+ -- 116.911 linhas para jogar 116.861 fora.
1377
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_executed_idx
1378
+ ON public.plg_clinical_session_products USING btree (tenant_id, executed_on DESC);
1379
+ `;
1380
+ var MIGRATION_004_A_REGUA_VOLTA_A_SER_APURADA = `-- ============================================================================
1381
+ -- A r\xE9gua do atendimento volta a ser APURADA
1382
+ --
1383
+ -- SINTOMA: 6.236 linhas de exig\xEAncia atravessaram do V1 e est\xE3o congeladas.
1384
+ -- Nada no V2 as recalcula: um atendimento novo nasce sem r\xE9gua, e um antigo
1385
+ -- nunca responde de novo "o que este servi\xE7o exigia". A r\xE9gua migrada \xE9 foto,
1386
+ -- n\xE3o motor.
1387
+ --
1388
+ -- O V1 resolve isso em duas fun\xE7\xF5es (\`materialize_execution_requirements\` e
1389
+ -- \`refresh_execution_requirements\`, migration 20260927130000). Esta migration \xE9
1390
+ -- o espelho delas sobre o modelo do V2, com tr\xEAs diferen\xE7as declaradas:
1391
+ --
1392
+ -- 1. CONTRATO E TERMO DE SESS\xC3O FICAM DE FORA. Os blocos d, e, f do V1
1393
+ -- dependem de \`contract_templates\` e \`generated_contracts\`, que o V2 n\xE3o
1394
+ -- modela. Emitir uma bola de contrato que nada pode resolver deixaria todo
1395
+ -- atendimento eternamente pendente \u2014 indicador errado \xE9 pior que nenhum.
1396
+ -- 2. O QUE A EXIG\xCANCIA PEDE ganha colunas. O V1 tem \`ref_id\` (o modelo de
1397
+ -- formul\xE1rio exigido); o ETL despejou isso em \`metadata.clinical.v1_ref_id\`
1398
+ -- e a tabela ficou sem discriminador \u2014 por isso h\xE1 1.378 grupos
1399
+ -- (session, kind) repetidos, que s\xE3o formul\xE1rios diferentes indistingu\xEDveis.
1400
+ -- \`ref_type\`/\`ref_id\` devolvem a pergunta; a chave \xFAnica cobre s\xF3 as linhas
1401
+ -- que este motor escreve, porque as migradas n\xE3o t\xEAm como ser desempatadas
1402
+ -- enquanto os modelos de formul\xE1rio n\xE3o chegarem.
1403
+ -- 3. DUAS BANDEIRAS DE SERVI\xC7O n\xE3o vieram do V1 (\`requires_evaluation\` e
1404
+ -- \`always_ask_used_products\`). Sem elas os blocos g e i ficariam mudos,
1405
+ -- ent\xE3o entram em \`products\` \u2014 onde o servi\xE7o mora no V2.
1406
+ --
1407
+ -- Modo: \`off\` (nada acontece) \xB7 \`shadow\` (grava e n\xE3o muda tela) \xB7 \`on\`.
1408
+ -- L\xEA-se de \`tenants.settings -> 'execution_requirements' ->> 'mode'\`.
1409
+ -- ============================================================================
1410
+
1411
+ -- \u2500\u2500\u2500 0. O que faltava para a pergunta caber \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1412
+
1413
+ ALTER TABLE public.products
1414
+ ADD COLUMN IF NOT EXISTS requires_evaluation boolean NOT NULL DEFAULT false,
1415
+ ADD COLUMN IF NOT EXISTS always_ask_used_products boolean NOT NULL DEFAULT false;
1416
+
1417
+ COMMENT ON COLUMN public.products.requires_evaluation IS
1418
+ 'Servi\xE7o que s\xF3 se executa depois de uma avalia\xE7\xE3o vinculada (bloco g da r\xE9gua).';
1419
+ COMMENT ON COLUMN public.products.always_ask_used_products IS
1420
+ 'Servi\xE7o que sempre pergunta os produtos usados, mesmo sem ficha t\xE9cnica (bloco i).';
1421
+
1422
+ ALTER TABLE public.plg_clinical_requirements
1423
+ ADD COLUMN IF NOT EXISTS ref_type text,
1424
+ ADD COLUMN IF NOT EXISTS ref_id uuid;
1425
+
1426
+ COMMENT ON COLUMN public.plg_clinical_requirements.ref_type IS
1427
+ 'O que a exig\xEAncia PEDE (form_template). N\xE3o confundir com subject_*, que \xE9 o que a RESOLVEU.';
1428
+
1429
+ -- Chave \xFAnica s\xF3 das linhas deste motor: as migradas perderam o discriminador
1430
+ -- (ver cabe\xE7alho) e colidiriam entre si.
1431
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_clinical_requirements_engine_key
1432
+ ON public.plg_clinical_requirements (session_id, kind, (coalesce(ref_id, '00000000-0000-0000-0000-000000000000'::uuid)))
1433
+ WHERE metadata -> 'migration' IS NULL;
1434
+
1435
+ CREATE INDEX IF NOT EXISTS plg_clinical_requirements_booking_status
1436
+ ON public.plg_clinical_requirements (booking_id, status);
1437
+
1438
+ -- \u2500\u2500\u2500 1. O modo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1439
+
1440
+ CREATE OR REPLACE FUNCTION public.clinical_requirements_mode(p_tenant uuid)
1441
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public'
1442
+ AS $fn$
1443
+ SELECT CASE lower(coalesce(t.settings -> 'execution_requirements' ->> 'mode', 'off'))
1444
+ WHEN 'shadow' THEN 'shadow'
1445
+ WHEN 'on' THEN 'on'
1446
+ ELSE 'off'
1447
+ END
1448
+ FROM public.tenants t WHERE t.id = p_tenant;
1449
+ $fn$;
1450
+
1451
+ -- \u2500\u2500\u2500 2. Uma linha, idempotente (o OR dos bloqueios \xE9 do V1) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1452
+
1453
+ CREATE OR REPLACE FUNCTION public.clinical_requirement_upsert(
1454
+ p_tenant uuid, p_unit uuid, p_booking uuid, p_session uuid, p_party uuid, p_service uuid,
1455
+ p_kind text, p_scope text, p_ref_type text, p_ref_id uuid, p_rule text,
1456
+ p_blocks_start boolean, p_blocks_finish boolean)
1457
+ RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public'
1458
+ AS $fn$
1459
+ DECLARE v_id uuid;
1460
+ BEGIN
1461
+ SELECT r.id INTO v_id FROM public.plg_clinical_requirements r
1462
+ WHERE r.session_id = p_session AND r.kind = p_kind
1463
+ AND coalesce(r.ref_id, '00000000-0000-0000-0000-000000000000'::uuid)
1464
+ = coalesce(p_ref_id, '00000000-0000-0000-0000-000000000000'::uuid)
1465
+ AND r.metadata -> 'migration' IS NULL;
1466
+
1467
+ IF v_id IS NULL THEN
1468
+ INSERT INTO public.plg_clinical_requirements (
1469
+ tenant_id, unit_id, booking_id, session_id, party_id, service_id,
1470
+ kind, scope, ref_type, ref_id, rule_sources, blocks_start, blocks_finish, status)
1471
+ VALUES (p_tenant, p_unit, p_booking, p_session, p_party, p_service,
1472
+ p_kind, p_scope, p_ref_type, p_ref_id, jsonb_build_array(p_rule),
1473
+ coalesce(p_blocks_start, false), coalesce(p_blocks_finish, false), 'pending');
1474
+ RETURN 1;
1475
+ END IF;
1476
+
1477
+ -- J\xE1 existe: soma a origem da regra e faz o OR dos bloqueios. Uma linha
1478
+ -- resolvida ou congelada n\xE3o volta a pendente por aqui.
1479
+ UPDATE public.plg_clinical_requirements r
1480
+ SET rule_sources = CASE WHEN r.rule_sources @> to_jsonb(p_rule) THEN r.rule_sources
1481
+ ELSE r.rule_sources || to_jsonb(p_rule) END,
1482
+ blocks_start = r.blocks_start OR coalesce(p_blocks_start, false),
1483
+ blocks_finish = r.blocks_finish OR coalesce(p_blocks_finish, false),
1484
+ status = CASE WHEN r.status = 'not_applicable' THEN 'pending' ELSE r.status END,
1485
+ updated_at = now()
1486
+ WHERE r.id = v_id AND r.frozen_at IS NULL;
1487
+ RETURN 0;
1488
+ END $fn$;
1489
+
1490
+ -- \u2500\u2500\u2500 3. Materializar as linhas em branco de UMA sess\xE3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1491
+
1492
+ CREATE OR REPLACE FUNCTION public.clinical_requirements_materialize(p_session uuid)
1493
+ RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public'
1494
+ AS $fn$
1495
+ DECLARE
1496
+ s record; sv record; r record; cfg jsonb;
1497
+ v_mode text; v_n int := 0; v_booking_status text; v_rule text;
1498
+ v_is_revision boolean; v_has_parent boolean; v_rev_num int; v_kind text;
1499
+ BEGIN
1500
+ SELECT * INTO s FROM public.plg_clinical_sessions WHERE id = p_session;
1501
+ IF NOT FOUND OR s.booking_id IS NULL THEN RETURN 0; END IF;
1502
+
1503
+ v_mode := public.clinical_requirements_mode(s.tenant_id);
1504
+ IF v_mode = 'off' THEN RETURN 0; END IF;
1505
+
1506
+ SELECT a.status INTO v_booking_status FROM public.appointments a WHERE a.id = s.booking_id;
1507
+ -- Hist\xF3rico n\xE3o ganha linha: sess\xE3o j\xE1 executada em atendimento que n\xE3o est\xE1
1508
+ -- em curso \xE9 fato consumado (mesma regra do V1).
1509
+ IF s.status IN ('executed', 'invoiced') AND coalesce(v_booking_status, '') <> 'in_progress' THEN
1510
+ RETURN 0;
1511
+ END IF;
1512
+
1513
+ BEGIN
1514
+ SELECT p.service_kind, p.requires_evaluation, p.always_ask_used_products, p.category_id
1515
+ INTO sv FROM public.products p WHERE p.id = s.service_id;
1516
+ IF NOT FOUND THEN RETURN 0; END IF;
1517
+ v_kind := coalesce(sv.service_kind, 'standard');
1518
+
1519
+ -- QUEM \xC9 REVIS\xC3O. O V1 tem um booleano (\`is_revision\`) ao lado do n\xFAmero.
1520
+ -- No V2 sobrou s\xF3 o n\xFAmero \u2014 e o ETL gravou \`revision_number = 1\` em TODA
1521
+ -- sess\xE3o, ent\xE3o o n\xFAmero n\xE3o distingue nada: lido como "\xE9 revis\xE3o", ele
1522
+ -- transformou 54 de 54 sess\xF5es em revis\xE3o, tirou o item de fatura de 21
1523
+ -- delas e inventou v\xEDnculo de revis\xE3o em 43. O que de fato separa as duas
1524
+ -- coisas no V2 \xE9 o PAI: revis\xE3o aponta para a sess\xE3o original. Conferido
1525
+ -- contra as 288 exig\xEAncias migradas destes mesmos atendimentos: o V1
1526
+ -- gravou \`invoice_item\` exatamente nas 21 sess\xF5es \`standard\` sem pai, e
1527
+ -- \`revision_link\` exatamente nas 8 de \`return\` mais as 3 \`standard\` com pai.
1528
+ v_rev_num := coalesce(s.revision_number, 0);
1529
+ v_has_parent := s.parent_session_id IS NOT NULL;
1530
+ v_is_revision := v_has_parent OR v_kind = 'return';
1531
+
1532
+ cfg := coalesce((SELECT t.settings -> 'evaluation_flow' FROM public.tenants t WHERE t.id = s.tenant_id), '{}'::jsonb);
1533
+ IF jsonb_typeof(cfg) <> 'object' THEN cfg := '{}'::jsonb; END IF;
1534
+
1535
+ -- a) Formul\xE1rios universais do tenant \u2014 escopo atendimento.
1536
+ FOR r IN SELECT ft.id FROM public.plg_forms_templates ft
1537
+ WHERE ft.tenant_id = s.tenant_id AND ft.is_active AND coalesce(ft.is_deleted, false) = false
1538
+ AND coalesce(ft.required_on_attendance, false)
1539
+ LOOP
1540
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1541
+ s.party_id, s.service_id, 'form', 'attendance', 'form_template', r.id,
1542
+ 'forms_templates.required_on_attendance', false, false);
1543
+ END LOOP;
1544
+
1545
+ IF NOT v_has_parent THEN
1546
+ -- b) Formul\xE1rios do servi\xE7o.
1547
+ FOR r IN SELECT sd.template_id, coalesce(sd.requirement_scope, 'attendance') AS scope
1548
+ FROM public.plg_forms_service_defaults sd
1549
+ JOIN public.plg_forms_templates ft ON ft.id = sd.template_id AND ft.is_active
1550
+ WHERE sd.product_id = s.service_id AND sd.tenant_id = s.tenant_id
1551
+ LOOP
1552
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1553
+ s.party_id, s.service_id, 'form',
1554
+ CASE WHEN r.scope = 'service' THEN 'service' ELSE 'attendance' END,
1555
+ 'form_template', r.template_id, 'forms_service_defaults', false, false);
1556
+ END LOOP;
1557
+ ELSE
1558
+ -- c) Formul\xE1rios da revis\xE3o, pelo n\xFAmero dela. No V2 os modelos da revis\xE3o
1559
+ -- moram numa lista dentro da pr\xF3pria revis\xE3o.
1560
+ FOR r IN SELECT (e.value #>> '{}')::uuid AS template_id
1561
+ FROM public.plg_clinical_service_revisions rev,
1562
+ LATERAL jsonb_array_elements(coalesce(rev.form_template_refs, '[]'::jsonb)) e
1563
+ WHERE rev.tenant_id = s.tenant_id AND rev.service_id = s.service_id
1564
+ AND rev.revision_number = v_rev_num
1565
+ AND (e.value #>> '{}') ~ '^[0-9a-f-]{36}$'
1566
+ LOOP
1567
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1568
+ s.party_id, s.service_id, 'form', 'service', 'form_template', r.template_id,
1569
+ 'clinical_service_revisions.form_template_refs', false, false);
1570
+ END LOOP;
1571
+ END IF;
1572
+
1573
+ -- d, e, f) CONTRATO e TERMO DE SESS\xC3O: fora, por falta de modelo no V2.
1574
+ -- Ver o cabe\xE7alho desta migration.
1575
+
1576
+ -- g) Avalia\xE7\xE3o vinculada.
1577
+ IF v_kind = 'standard' THEN
1578
+ IF coalesce(sv.requires_evaluation, false) THEN
1579
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1580
+ s.party_id, s.service_id, 'evaluation', 'service', NULL, NULL,
1581
+ 'products.requires_evaluation', false, false);
1582
+ ELSIF coalesce((cfg ->> 'require_evaluation_for_kind_service')::boolean, false) THEN
1583
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1584
+ s.party_id, s.service_id, 'evaluation', 'service', NULL, NULL,
1585
+ 'evaluation_flow.require_evaluation_for_kind_service', false, false);
1586
+ END IF;
1587
+ END IF;
1588
+
1589
+ -- h) Revis\xE3o vinculada ao servi\xE7o original: trava come\xE7ar E terminar.
1590
+ IF v_is_revision AND coalesce((cfg ->> 'revision_link_required')::boolean, false) THEN
1591
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1592
+ s.party_id, s.service_id, 'revision_link', 'service', NULL, NULL,
1593
+ 'evaluation_flow.revision_link_required', true, true);
1594
+ END IF;
1595
+
1596
+ -- i) Produtos utilizados: ficha t\xE9cnica, "sempre perguntar", ou j\xE1 lan\xE7ado.
1597
+ v_rule := NULL;
1598
+ IF EXISTS (SELECT 1 FROM public.plg_inventory_recipes rc
1599
+ WHERE rc.product_id = s.service_id AND rc.tenant_id = s.tenant_id
1600
+ AND coalesce(rc.is_active, true)) THEN
1601
+ v_rule := 'inventory_recipes';
1602
+ ELSIF coalesce(sv.always_ask_used_products, false) THEN
1603
+ v_rule := 'products.always_ask_used_products';
1604
+ ELSIF EXISTS (SELECT 1 FROM public.plg_clinical_session_products sp
1605
+ WHERE sp.session_id = s.id
1606
+ OR (s.order_item_id IS NOT NULL AND sp.order_item_id = s.order_item_id)) THEN
1607
+ v_rule := 'clinical_session_products';
1608
+ END IF;
1609
+ IF v_rule IS NOT NULL THEN
1610
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1611
+ s.party_id, s.service_id, 'products', 'service', NULL, NULL, v_rule, false, false);
1612
+ END IF;
1613
+
1614
+ -- j) Item de fatura: servi\xE7o comum, n\xE3o-revis\xE3o.
1615
+ IF v_kind = 'standard' AND NOT v_has_parent THEN
1616
+ v_n := v_n + public.clinical_requirement_upsert(s.tenant_id, s.unit_id, s.booking_id, s.id,
1617
+ s.party_id, s.service_id, 'invoice_item', 'service', NULL, NULL, 'billing', false, false);
1618
+ END IF;
1619
+ EXCEPTION WHEN others THEN
1620
+ -- Nunca derruba quem chama: a fonte (a sess\xE3o) n\xE3o pode falhar por causa da r\xE9gua.
1621
+ RAISE WARNING 'clinical_requirements_materialize(%): %', p_session, sqlerrm;
1622
+ RETURN 0;
1623
+ END;
1624
+ RETURN v_n;
1625
+ END $fn$;
1626
+
1627
+ -- \u2500\u2500\u2500 4. Reapurar as linhas de UM atendimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1628
+
1629
+ CREATE OR REPLACE FUNCTION public.clinical_requirements_refresh(p_booking uuid, p_final boolean DEFAULT false)
1630
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public'
1631
+ AS $fn$
1632
+ DECLARE
1633
+ a record; v_mode text; v_now timestamptz := now(); v_eval jsonb;
1634
+ n_mat int := 0; n_reopen int := 0;
1635
+ -- \`v_sessao\`, n\xE3o \`r\`: plpgsql resolve o nome da VARI\xC1VEL antes do apelido da
1636
+ -- tabela, ent\xE3o um \`record r\` declarado aqui sequestra todo \`r.coluna\` dos
1637
+ -- UPDATEs abaixo \u2014 e o erro que sai \xE9 "record r has no field booking_id".
1638
+ v_sessao record;
1639
+ BEGIN
1640
+ SELECT ap.id, ap.tenant_id, ap.party_id, ap.status INTO a
1641
+ FROM public.appointments ap WHERE ap.id = p_booking;
1642
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
1643
+
1644
+ v_mode := public.clinical_requirements_mode(a.tenant_id);
1645
+ IF v_mode = 'off' THEN RETURN jsonb_build_object('ok', true, 'mode', v_mode, 'skipped', true); END IF;
1646
+
1647
+ p_final := coalesce(p_final, false) AND a.status = 'completed';
1648
+
1649
+ BEGIN
1650
+ PERFORM pg_advisory_xact_lock(hashtextextended('clinical_req:' || p_booking::text, 0));
1651
+ IF a.status = 'completed' AND NOT p_final THEN
1652
+ RETURN jsonb_build_object('ok', true, 'mode', v_mode, 'frozen', true);
1653
+ END IF;
1654
+
1655
+ -- 1) Materializa o que falta (sess\xE3o nova, ficha t\xE9cnica criada depois).
1656
+ FOR v_sessao IN SELECT cs.id FROM public.plg_clinical_sessions cs WHERE cs.booking_id = p_booking
1657
+ LOOP
1658
+ n_mat := n_mat + public.clinical_requirements_materialize(v_sessao.id);
1659
+ END LOOP;
1660
+
1661
+ v_eval := coalesce((SELECT t.settings -> 'evaluation_flow' FROM public.tenants t WHERE t.id = a.tenant_id), '{}'::jsonb);
1662
+ IF jsonb_typeof(v_eval) <> 'object' THEN v_eval := '{}'::jsonb; END IF;
1663
+
1664
+ -- 1b) Regra que saiu da configura\xE7\xE3o para de bloquear (o upsert reabre se voltar).
1665
+ UPDATE public.plg_clinical_requirements r
1666
+ SET status = 'not_applicable', candidate_type = NULL, candidate_id = NULL, updated_at = v_now
1667
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.frozen_at IS NULL
1668
+ AND r.metadata -> 'migration' IS NULL
1669
+ AND ((r.kind = 'revision_link' AND NOT coalesce((v_eval ->> 'revision_link_required')::boolean, false))
1670
+ OR (r.kind = 'evaluation'
1671
+ AND r.rule_sources = '["evaluation_flow.require_evaluation_for_kind_service"]'::jsonb
1672
+ AND NOT coalesce((v_eval ->> 'require_evaluation_for_kind_service')::boolean, false)));
1673
+
1674
+ -- 2) Reabre o que perdeu a fonte. Linha congelada n\xE3o se mexe.
1675
+ UPDATE public.plg_clinical_requirements r
1676
+ SET status = 'pending', subject_type = NULL, subject_id = NULL, resolved_via = NULL,
1677
+ resolved_at = NULL, valid_until = NULL, updated_at = v_now
1678
+ WHERE r.booking_id = p_booking AND r.status = 'resolved' AND r.frozen_at IS NULL
1679
+ AND ((r.subject_type = 'documents' AND NOT EXISTS (SELECT 1 FROM public.documents d WHERE d.id = r.subject_id))
1680
+ OR (r.subject_type = 'clinical_sessions' AND NOT EXISTS (SELECT 1 FROM public.plg_clinical_sessions x WHERE x.id = r.subject_id))
1681
+ OR (r.subject_type = 'items' AND NOT EXISTS (SELECT 1 FROM public.items oi WHERE oi.id = r.subject_id)));
1682
+ GET DIAGNOSTICS n_reopen = ROW_COUNT;
1683
+
1684
+ -- 3) Formul\xE1rio universal: a \xFAltima resposta do cliente, em qualquer
1685
+ -- atendimento, ainda dentro da validade do modelo.
1686
+ UPDATE public.plg_clinical_requirements r
1687
+ SET status = 'resolved', subject_type = 'documents', subject_id = x.id,
1688
+ resolved_via = CASE WHEN x.subject_id = p_booking THEN 'attendance' ELSE 'auto_client' END,
1689
+ resolved_at = v_now,
1690
+ valid_until = CASE WHEN x.validity_days IS NULL THEN NULL
1691
+ ELSE x.eff + make_interval(days => x.validity_days) END,
1692
+ updated_at = v_now
1693
+ FROM (SELECT DISTINCT ON (fd.template_id) d.id, fd.template_id, d.subject_id,
1694
+ coalesce(d.updated_at, d.created_at) AS eff, ft.validity_days
1695
+ FROM public.plg_forms_documents fd
1696
+ JOIN public.documents d ON d.id = fd.document_id
1697
+ JOIN public.plg_forms_templates ft ON ft.id = fd.template_id
1698
+ WHERE d.person_id = a.party_id AND d.tenant_id = a.tenant_id
1699
+ ORDER BY fd.template_id, coalesce(d.updated_at, d.created_at) DESC) x
1700
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'form'
1701
+ AND r.scope = 'attendance' AND r.ref_id = x.template_id
1702
+ AND r.rule_sources @> '["forms_templates.required_on_attendance"]'::jsonb
1703
+ AND (x.validity_days IS NULL OR x.eff + make_interval(days => x.validity_days) > v_now);
1704
+
1705
+ -- 4) Demais formul\xE1rios: qualquer resposta DESTE atendimento com o modelo.
1706
+ UPDATE public.plg_clinical_requirements r
1707
+ SET status = 'resolved', subject_type = 'documents', subject_id = x.id,
1708
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
1709
+ FROM (SELECT DISTINCT ON (fd.template_id, coalesce(fd.product_id, '00000000-0000-0000-0000-000000000000'::uuid))
1710
+ d.id, fd.template_id, fd.product_id
1711
+ FROM public.plg_forms_documents fd
1712
+ JOIN public.documents d ON d.id = fd.document_id
1713
+ WHERE d.subject_type = 'appointment' AND d.subject_id = p_booking
1714
+ ORDER BY fd.template_id, coalesce(fd.product_id, '00000000-0000-0000-0000-000000000000'::uuid),
1715
+ coalesce(d.updated_at, d.created_at) DESC) x
1716
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'form'
1717
+ AND r.ref_id = x.template_id
1718
+ AND NOT (r.rule_sources @> '["forms_templates.required_on_attendance"]'::jsonb)
1719
+ AND (x.product_id IS NULL OR r.scope = 'attendance' OR x.product_id = r.service_id);
1720
+
1721
+ -- 5) Avalia\xE7\xE3o vinculada: a sess\xE3o aponta a avalia\xE7\xE3o que a autorizou.
1722
+ UPDATE public.plg_clinical_requirements r
1723
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = x.eval_id,
1724
+ resolved_via = CASE WHEN x.same_booking THEN 'attendance' ELSE 'auto_client' END,
1725
+ resolved_at = v_now, updated_at = v_now
1726
+ FROM (SELECT r2.id AS req_id, e.id AS eval_id, (e.booking_id = p_booking) AS same_booking
1727
+ FROM public.plg_clinical_requirements r2
1728
+ JOIN public.plg_clinical_sessions cs ON cs.id = r2.session_id
1729
+ JOIN public.plg_clinical_sessions e ON e.id = cs.source_evaluation_id
1730
+ WHERE r2.booking_id = p_booking AND r2.kind = 'evaluation' AND r2.frozen_at IS NULL
1731
+ AND (r2.status = 'pending' OR (r2.status = 'resolved' AND r2.resolved_via = 'legacy_grace'))) x
1732
+ WHERE r.id = x.req_id;
1733
+
1734
+ -- 5b) Gra\xE7a legada: o cliente j\xE1 tem atendimento conclu\xEDdo com servi\xE7o de
1735
+ -- avalia\xE7\xE3o. \xC9 a mesma concess\xE3o do V1, e vai marcada como tal.
1736
+ UPDATE public.plg_clinical_requirements r
1737
+ SET status = 'resolved', subject_type = 'appointments', subject_id = x.booking_id,
1738
+ resolved_via = 'legacy_grace', resolved_at = v_now, updated_at = v_now
1739
+ FROM (SELECT ap.id AS booking_id
1740
+ FROM public.appointments ap
1741
+ JOIN public.appointment_items ai ON ai.booking_id = ap.id
1742
+ JOIN public.products p ON p.id = ai.service_id AND p.service_kind = 'evaluation'
1743
+ WHERE ap.party_id = a.party_id AND ap.tenant_id = a.tenant_id AND ap.status = 'completed'
1744
+ ORDER BY ap.starts_at DESC NULLS LAST LIMIT 1) x
1745
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'evaluation';
1746
+
1747
+ -- 6) Revis\xE3o vinculada ao servi\xE7o original.
1748
+ UPDATE public.plg_clinical_requirements r
1749
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = cs.parent_session_id,
1750
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
1751
+ FROM public.plg_clinical_sessions cs
1752
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
1753
+ AND r.status = 'pending' AND r.kind = 'revision_link'
1754
+ AND cs.parent_session_id IS NOT NULL
1755
+ AND EXISTS (SELECT 1 FROM public.plg_clinical_sessions p WHERE p.id = cs.parent_session_id);
1756
+
1757
+ -- 7) Produtos: a sess\xE3o declara que foram conferidos.
1758
+ UPDATE public.plg_clinical_requirements r
1759
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = cs.id,
1760
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
1761
+ FROM public.plg_clinical_sessions cs
1762
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
1763
+ AND r.status = 'pending' AND r.kind = 'products'
1764
+ AND coalesce(cs.products_initialized, false);
1765
+
1766
+ -- 8) Item de pedido vinculado. A linha do pedido no V2 \xE9 \`public.items\` \u2014
1767
+ -- n\xE3o existe \`order_items\`; o pedido \xE9 o documento e o item \xE9 dele.
1768
+ UPDATE public.plg_clinical_requirements r
1769
+ SET status = 'resolved', subject_type = 'items', subject_id = oi.id,
1770
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
1771
+ FROM public.plg_clinical_sessions cs
1772
+ JOIN public.items oi ON oi.id = cs.order_item_id
1773
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
1774
+ AND r.status = 'pending' AND r.kind = 'invoice_item';
1775
+
1776
+ -- 9) Congelamento: s\xF3 na \xFAltima passada, com o atendimento conclu\xEDdo.
1777
+ IF p_final THEN
1778
+ UPDATE public.plg_clinical_requirements
1779
+ SET frozen_at = coalesce(frozen_at, v_now), updated_at = v_now
1780
+ WHERE booking_id = p_booking;
1781
+ END IF;
1782
+
1783
+ RETURN jsonb_build_object(
1784
+ 'ok', true, 'mode', v_mode, 'materialized', n_mat, 'reopened', n_reopen,
1785
+ 'pending', (SELECT count(*) FROM public.plg_clinical_requirements WHERE booking_id = p_booking AND status = 'pending'),
1786
+ 'resolved', (SELECT count(*) FROM public.plg_clinical_requirements WHERE booking_id = p_booking AND status = 'resolved'),
1787
+ 'frozen', p_final);
1788
+ EXCEPTION WHEN others THEN
1789
+ RAISE WARNING 'clinical_requirements_refresh(%): %', p_booking, sqlerrm;
1790
+ RETURN jsonb_build_object('ok', false, 'error', sqlerrm);
1791
+ END;
1792
+ END $fn$;
1793
+
1794
+ -- \u2500\u2500\u2500 5. Quem pode chamar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1795
+ -- Escrita s\xF3 pelas fun\xE7\xF5es (definer). O app chama o refresh; o resto \xE9 interno.
1796
+
1797
+ REVOKE ALL ON FUNCTION public.clinical_requirement_upsert(uuid, uuid, uuid, uuid, uuid, uuid, text, text, text, uuid, text, boolean, boolean) FROM public, anon, authenticated;
1798
+ REVOKE ALL ON FUNCTION public.clinical_requirements_materialize(uuid) FROM public, anon, authenticated;
1799
+ GRANT EXECUTE ON FUNCTION public.clinical_requirements_mode(uuid) TO authenticated;
1800
+ GRANT EXECUTE ON FUNCTION public.clinical_requirements_refresh(uuid, boolean) TO authenticated;
1801
+ `;
1802
+ var MIGRATION_005_COMECAR_O_ATENDIMENTO_E_UM_GESTO_SO = `-- ============================================================================
1803
+ -- Come\xE7ar o atendimento \xE9 UM gesto \u2014 e uma transa\xE7\xE3o s\xF3
1804
+ --
1805
+ -- No V1, "Iniciar Atendimento" grava tr\xEAs coisas no mesmo instante: o
1806
+ -- agendamento vira \`in_progress\`, a execu\xE7\xE3o de cada servi\xE7o nasce, e a r\xE9gua \xE9
1807
+ -- materializada (\`rpc/refresh_execution_requirements\`). Se qualquer uma falhar
1808
+ -- sozinha o atendimento fica meio aberto: status trocado sem r\xE9gua, ou r\xE9gua
1809
+ -- sem sess\xE3o \u2014 e a tela mostra uma bola que n\xE3o pertence a nada.
1810
+ --
1811
+ -- Por isso \xE9 uma fun\xE7\xE3o e n\xE3o tr\xEAs chamadas do app. A recep\xE7\xE3o e o profissional
1812
+ -- podem apertar o bot\xE3o ao mesmo tempo, e o segundo n\xE3o pode duplicar sess\xE3o.
1813
+ --
1814
+ -- QUEM PODE: o V1 s\xF3 monta a r\xE9gua para quem est\xE1 VINCULADO A UM PROFISSIONAL,
1815
+ -- e s\xF3 enxerga o atendimento DELE com aquele cliente. Aqui a regra \xE9 a mesma \u2014
1816
+ -- e a recusa \xE9 FALADA (\`reason\`), n\xE3o silenciosa: a tela precisa dizer por que
1817
+ -- o bot\xE3o n\xE3o serve, em vez de sumir com ele.
1818
+ -- ============================================================================
1819
+
1820
+ CREATE OR REPLACE FUNCTION public.clinical_attendance_start(p_booking uuid)
1821
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
1822
+ AS $fn$
1823
+ DECLARE
1824
+ a record; v_person uuid; v_created int := 0; v_item record; v_refresh jsonb;
1825
+ BEGIN
1826
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.starts_at
1827
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
1828
+ IF NOT FOUND THEN
1829
+ RETURN jsonb_build_object('ok', false, 'reason', 'not_found');
1830
+ END IF;
1831
+
1832
+ -- A pessoa por tr\xE1s do login. Nulo = login sem ficha (o dono que criou o
1833
+ -- espa\xE7o, a licen\xE7a de suporte): v\xEA tudo, atende ningu\xE9m.
1834
+ v_person := public.current_person_id();
1835
+ IF v_person IS NULL THEN
1836
+ RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional');
1837
+ END IF;
1838
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
1839
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
1840
+ END IF;
1841
+ -- Atender o que n\xE3o \xE9 de hoje abriria um atendimento paralelo ao agendado \u2014
1842
+ -- o mesmo motivo que fez o V1 trocar o bot\xE3o conforme o dia.
1843
+ IF a.starts_at::date <> (now() AT TIME ZONE 'America/Sao_Paulo')::date THEN
1844
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_de_hoje');
1845
+ END IF;
1846
+ IF a.status IN ('completed', 'cancelled', 'no_show') THEN
1847
+ RETURN jsonb_build_object('ok', false, 'reason', 'ja_encerrado', 'status', a.status);
1848
+ END IF;
1849
+
1850
+ -- Uma sess\xE3o por servi\xE7o do agendamento. \`booking_item_id\` \xE9 a chave de
1851
+ -- idempot\xEAncia: apertar duas vezes (ou duas pessoas ao mesmo tempo) n\xE3o
1852
+ -- duplica nada.
1853
+ FOR v_item IN
1854
+ SELECT ai.id, ai.service_id, ai.assignee_id
1855
+ FROM public.appointment_items ai
1856
+ WHERE ai.booking_id = p_booking
1857
+ LOOP
1858
+ INSERT INTO public.plg_clinical_sessions (
1859
+ tenant_id, unit_id, status, booking_id, booking_item_id, party_id,
1860
+ assignee_id, performed_by_id, service_id, scheduled_for, started_at)
1861
+ SELECT a.tenant_id, a.unit_id, 'in_service', a.id, v_item.id, a.party_id,
1862
+ coalesce(v_item.assignee_id, a.assignee_id), v_person, v_item.service_id,
1863
+ a.starts_at, now()
1864
+ WHERE NOT EXISTS (
1865
+ SELECT 1 FROM public.plg_clinical_sessions cs
1866
+ WHERE cs.booking_id = p_booking AND cs.booking_item_id = v_item.id);
1867
+ IF FOUND THEN v_created := v_created + 1; END IF;
1868
+ END LOOP;
1869
+
1870
+ UPDATE public.appointments
1871
+ SET status = 'in_progress', updated_at = now()
1872
+ WHERE id = p_booking AND status <> 'in_progress';
1873
+
1874
+ v_refresh := public.clinical_requirements_refresh(p_booking);
1875
+
1876
+ RETURN jsonb_build_object(
1877
+ 'ok', true,
1878
+ 'sessoes_criadas', v_created,
1879
+ 'sessoes', (SELECT count(*) FROM public.plg_clinical_sessions WHERE booking_id = p_booking),
1880
+ 'regua', v_refresh);
1881
+ END $fn$;
1882
+
1883
+ GRANT EXECUTE ON FUNCTION public.clinical_attendance_start(uuid) TO authenticated;
1884
+ `;
1885
+ var MIGRATION_006_CONTRATAR_NAO_E_PAGAR = `-- ============================================================================
1886
+ -- CONTRATAR n\xE3o \xE9 PAGAR
1887
+ --
1888
+ -- O passo mais delicado do turno do injetor, e a demonstra\xE7\xE3o do V1 para nele
1889
+ -- para explicar: *"Mas at\xE9 ent\xE3o ele n\xE3o pagou ainda? N\xE3o pagou \u2014 mas ele vai
1890
+ -- executar."* Contratar cria a D\xCDVIDA; receber \xE9 papel da recep\xE7\xE3o, em outra
1891
+ -- tela e outro momento.
1892
+ --
1893
+ -- No V1 isto grava \`generated_contracts\` + \`invoices\` + \`invoice_items\`. No V2
1894
+ -- o documento \xE9 o PEDIDO, e h\xE1 uma porta s\xF3 para abri-lo: \`order_create\` \u2014 a
1895
+ -- mesma que o PDV, o totem e a agenda usam. Nada aqui escreve uma linha de
1896
+ -- fatura \xE0 m\xE3o; a cobran\xE7a nasce depois, quando o atendimento fecha.
1897
+ --
1898
+ -- Por que uma fun\xE7\xE3o e n\xE3o tr\xEAs chamadas do app: contratar \xE9 abrir o pedido,
1899
+ -- ligar cada sess\xE3o \xE0 sua linha e reapurar a r\xE9gua. Pedido aberto com a sess\xE3o
1900
+ -- solta \xE9 um atendimento que cobra e n\xE3o sabe o que executou.
1901
+ -- ============================================================================
1902
+
1903
+ CREATE OR REPLACE FUNCTION public.clinical_contract_services(p_booking uuid, p_lines jsonb)
1904
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
1905
+ AS $fn$
1906
+ DECLARE
1907
+ a record; v_person uuid; v_order uuid; v_line jsonb; v_item uuid; v_n int := 0;
1908
+ v_rpc_lines jsonb := '[]'::jsonb; v_idx int := 0;
1909
+ BEGIN
1910
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.order_id
1911
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
1912
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
1913
+
1914
+ v_person := public.current_person_id();
1915
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
1916
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
1917
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
1918
+ END IF;
1919
+ IF a.status <> 'in_progress' THEN
1920
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
1921
+ END IF;
1922
+ IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN
1923
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_marcado');
1924
+ END IF;
1925
+
1926
+ -- As linhas, no formato que \`order_create\` j\xE1 entende.
1927
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
1928
+ LOOP
1929
+ v_rpc_lines := v_rpc_lines || jsonb_build_object(
1930
+ 'product_id', v_line ->> 'service_id',
1931
+ 'name', v_line ->> 'name',
1932
+ 'quantity', coalesce((v_line ->> 'quantity')::numeric, 1),
1933
+ 'unit_price', coalesce((v_line ->> 'unit_price')::numeric, 0),
1934
+ 'sort_order', v_idx);
1935
+ v_idx := v_idx + 1;
1936
+ END LOOP;
1937
+
1938
+ -- O pedido do atendimento. \`idempotency_key\` pelo agendamento: dois toques
1939
+ -- no "Contratar" n\xE3o abrem duas contas para a mesma visita.
1940
+ IF a.order_id IS NULL THEN
1941
+ v_order := public.order_create(
1942
+ p_kind => 'appointment',
1943
+ p_lines => v_rpc_lines,
1944
+ p_party_id => a.party_id,
1945
+ p_unit_id => a.unit_id,
1946
+ p_status => 'scheduled',
1947
+ p_assignee_id => a.assignee_id,
1948
+ p_metadata => jsonb_build_object('agenda', jsonb_build_object('booking_id', p_booking), 'source', 'clinical:contratacao'),
1949
+ p_idempotency_key => 'clinical-contract:' || p_booking::text);
1950
+ UPDATE public.appointments SET order_id = v_order, updated_at = now() WHERE id = p_booking;
1951
+ ELSE
1952
+ -- Pedido j\xE1 existe (a agenda abriu na marca\xE7\xE3o): acrescenta o que falta.
1953
+ v_order := a.order_id;
1954
+ v_idx := coalesce((SELECT max(i.sort_order) + 1 FROM public.items i WHERE i.order_id = v_order), 0);
1955
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
1956
+ LOOP
1957
+ INSERT INTO public.items (tenant_id, unit_id, order_id, product_id, name, quantity, unit_price, total, sort_order)
1958
+ VALUES (a.tenant_id, a.unit_id, v_order, (v_line ->> 'service_id')::uuid, v_line ->> 'name',
1959
+ coalesce((v_line ->> 'quantity')::numeric, 1),
1960
+ coalesce((v_line ->> 'unit_price')::numeric, 0),
1961
+ coalesce((v_line ->> 'quantity')::numeric, 1) * coalesce((v_line ->> 'unit_price')::numeric, 0),
1962
+ v_idx);
1963
+ v_idx := v_idx + 1;
1964
+ END LOOP;
1965
+ END IF;
1966
+
1967
+ -- Cada sess\xE3o passa a apontar a SUA linha. Sem isto o pedido cobra e o
1968
+ -- atendimento n\xE3o sabe o que executou \u2014 e a bola "Conta do atendimento"
1969
+ -- nunca fecha, porque \xE9 exatamente este v\xEDnculo que ela procura.
1970
+ UPDATE public.plg_clinical_sessions cs
1971
+ SET order_item_id = i.id,
1972
+ status = CASE WHEN cs.status IN ('quoted', 'pending', 'scheduled') THEN 'contracted' ELSE cs.status END,
1973
+ updated_at = now()
1974
+ FROM public.items i
1975
+ WHERE cs.booking_id = p_booking
1976
+ AND cs.order_item_id IS NULL
1977
+ AND i.order_id = v_order
1978
+ AND i.product_id = cs.service_id
1979
+ AND NOT EXISTS (SELECT 1 FROM public.plg_clinical_sessions o
1980
+ WHERE o.order_item_id = i.id AND o.id <> cs.id);
1981
+ GET DIAGNOSTICS v_n = ROW_COUNT;
1982
+
1983
+ RETURN jsonb_build_object(
1984
+ 'ok', true, 'order_id', v_order, 'sessoes_ligadas', v_n,
1985
+ 'total', (SELECT coalesce(sum(i.total), 0) FROM public.items i WHERE i.order_id = v_order),
1986
+ 'regua', public.clinical_requirements_refresh(p_booking));
1987
+ END $fn$;
1988
+
1989
+ GRANT EXECUTE ON FUNCTION public.clinical_contract_services(uuid, jsonb) TO authenticated;
1990
+ `;
1991
+ var MIGRATION_007_FINALIZAR_ACENDE_O_BALCAO = `-- ============================================================================
1992
+ -- FINALIZAR acende o balc\xE3o
1993
+ --
1994
+ -- O \xFAltimo gesto do profissional \xE9 o PRIMEIRO da recep\xE7\xE3o. No V1, o instante em
1995
+ -- que o atendimento vai para \`completed\` sem \`checkout_confirmed_at\` acende o
1996
+ -- rodap\xE9 do balc\xE3o \u2014 com contador, pulso e bipe.
1997
+ --
1998
+ -- Duas decis\xF5es do V1 que v\xEAm junto, e que s\xE3o o oposto uma da outra:
1999
+ --
2000
+ -- \xB7 o aviso de servi\xE7os n\xE3o executados AVISA E DEIXA PASSAR. Travar criaria
2001
+ -- um atendimento sem sa\xEDda pela interface; quem n\xE3o executou cancela o item
2002
+ -- na conta do cliente. Por isso \`p_force\`: a primeira chamada devolve a
2003
+ -- lista, a segunda executa.
2004
+ -- \xB7 a exig\xEAncia que bloqueia TERMINAR (\`blocks_finish\`) \xE9 outra coisa, e essa
2005
+ -- trava de verdade \u2014 \xE9 o termo de sess\xE3o do V1, que existe para n\xE3o haver
2006
+ -- execu\xE7\xE3o sem assinatura.
2007
+ --
2008
+ -- E o que N\xC3O se grava aqui: \`checkout_confirmed_at\`. Ele \xE9 da recep\xE7\xE3o, e \xE9 o
2009
+ -- que tira o cart\xE3o da fila \u2014 receber e conferir s\xE3o duas coisas.
2010
+ -- ============================================================================
2011
+
2012
+ CREATE OR REPLACE FUNCTION public.clinical_attendance_finish(p_booking uuid, p_force boolean DEFAULT false)
2013
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2014
+ AS $fn$
2015
+ DECLARE
2016
+ a record; v_person uuid; v_bloqueia jsonb; v_nao_executados jsonb;
2017
+ v_avisados int := 0; v_total numeric := 0; v_cliente text;
2018
+ BEGIN
2019
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.order_id
2020
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2021
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2022
+
2023
+ v_person := public.current_person_id();
2024
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2025
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2026
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2027
+ END IF;
2028
+ IF a.status <> 'in_progress' THEN
2029
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
2030
+ END IF;
2031
+
2032
+ -- A trava de verdade: exig\xEAncia que impede TERMINAR.
2033
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', r.kind, 'service_id', r.service_id)), '[]'::jsonb)
2034
+ INTO v_bloqueia
2035
+ FROM public.plg_clinical_requirements r
2036
+ WHERE r.booking_id = p_booking AND r.blocks_finish AND r.status = 'pending';
2037
+ IF jsonb_array_length(v_bloqueia) > 0 THEN
2038
+ RETURN jsonb_build_object('ok', false, 'reason', 'exigencia_bloqueia', 'itens', v_bloqueia);
2039
+ END IF;
2040
+
2041
+ -- O aviso que n\xE3o trava: servi\xE7os contratados e ainda n\xE3o executados.
2042
+ SELECT coalesce(jsonb_agg(p.name ORDER BY p.name), '[]'::jsonb) INTO v_nao_executados
2043
+ FROM public.plg_clinical_sessions cs
2044
+ JOIN public.products p ON p.id = cs.service_id
2045
+ WHERE cs.booking_id = p_booking AND cs.status <> 'executed';
2046
+ IF jsonb_array_length(v_nao_executados) > 0 AND NOT coalesce(p_force, false) THEN
2047
+ RETURN jsonb_build_object('ok', false, 'reason', 'servicos_nao_executados', 'itens', v_nao_executados);
2048
+ END IF;
2049
+
2050
+ -- "Finalizar agora registra estes servi\xE7os como executados" \u2014 texto do V1.
2051
+ UPDATE public.plg_clinical_sessions
2052
+ SET status = 'executed', ended_at = coalesce(ended_at, now()), updated_at = now()
2053
+ WHERE booking_id = p_booking AND status <> 'executed';
2054
+
2055
+ UPDATE public.appointments
2056
+ SET status = 'completed', updated_at = now()
2057
+ WHERE id = p_booking;
2058
+
2059
+ -- Congela a trilha: depois de encerrado, a r\xE9gua n\xE3o se reapura.
2060
+ PERFORM public.clinical_requirements_refresh(p_booking, true);
2061
+
2062
+ SELECT coalesce(sum(i.total), 0) INTO v_total FROM public.items i WHERE i.order_id = a.order_id;
2063
+ SELECT pe.name INTO v_cliente FROM public.people pe WHERE pe.id = a.party_id;
2064
+
2065
+ -- O BALC\xC3O ACENDE.
2066
+ --
2067
+ -- Quem recebe: a recep\xE7\xE3o da conta. Onde n\xE3o houver ningu\xE9m com o papel de
2068
+ -- recep\xE7\xE3o \u2014 e \xE9 o caso do tenant migrado, com 0 membros nele \u2014, cai para
2069
+ -- gerente e dono. Um aviso que n\xE3o chega a ningu\xE9m deixaria a conta parada
2070
+ -- sem que nada na tela dissesse por qu\xEA.
2071
+ WITH alvo AS (
2072
+ SELECT m.user_id
2073
+ FROM app.memberships m
2074
+ JOIN app.roles r ON r.id = m.role_id
2075
+ WHERE m.tenant_id = a.tenant_id AND coalesce(m.active, true)
2076
+ AND r.key IN ('recepcao', 'reception')
2077
+ UNION ALL
2078
+ SELECT m.user_id
2079
+ FROM app.memberships m
2080
+ JOIN app.roles r ON r.id = m.role_id
2081
+ WHERE m.tenant_id = a.tenant_id AND coalesce(m.active, true)
2082
+ AND r.key IN ('owner', 'manager')
2083
+ AND NOT EXISTS (
2084
+ SELECT 1 FROM app.memberships m2 JOIN app.roles r2 ON r2.id = m2.role_id
2085
+ WHERE m2.tenant_id = a.tenant_id AND coalesce(m2.active, true)
2086
+ AND r2.key IN ('recepcao', 'reception'))
2087
+ )
2088
+ INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)
2089
+ SELECT a.tenant_id, alvo.user_id,
2090
+ 'Checkout pendente',
2091
+ coalesce(v_cliente, 'Cliente') || ' terminou o atendimento'
2092
+ -- O dinheiro escrito como o Brasil escreve. \`to_char\` usa o
2093
+ -- \`lc_numeric\` do BANCO para o grupo e a v\xEDrgula, e o cluster est\xE1
2094
+ -- em C: sa\xEDa "R$ 999.00" no aviso de uma recep\xE7\xE3o brasileira. A
2095
+ -- troca \xE9 feita \xE0 m\xE3o para n\xE3o depender de locale nenhum.
2096
+ || CASE WHEN v_total > 0 THEN ' \xB7 R$ ' ||
2097
+ replace(replace(replace(to_char(v_total, 'FM999G999G999D00'), ',', '\xA7'), '.', ','), '\xA7', '.')
2098
+ ELSE '' END,
2099
+ 'checkout_pendente', '#/checkin', 'appointment', p_booking
2100
+ FROM alvo
2101
+ ON CONFLICT (user_id, subject_type, subject_id, type) WHERE subject_id IS NOT NULL DO NOTHING;
2102
+ GET DIAGNOSTICS v_avisados = ROW_COUNT;
2103
+
2104
+ RETURN jsonb_build_object(
2105
+ 'ok', true, 'avisados', v_avisados, 'total', v_total,
2106
+ 'executados', (SELECT count(*) FROM public.plg_clinical_sessions WHERE booking_id = p_booking AND status = 'executed'));
2107
+ END $fn$;
2108
+
2109
+ GRANT EXECUTE ON FUNCTION public.clinical_attendance_finish(uuid, boolean) TO authenticated;
2110
+ `;
2111
+ var MIGRATION_008_CONFERIR_NAO_E_RECEBER = `-- ============================================================================
2112
+ -- CONFERIR n\xE3o \xE9 RECEBER
2113
+ --
2114
+ -- O V1 \xE9 expl\xEDcito: *"Receber move dinheiro; concluir checkout \xE9 a recep\xE7\xE3o
2115
+ -- dizendo 'conferi a sess\xE3o'. O cart\xE3o fica na fila enquanto houver valor em
2116
+ -- aberto OU checkout por confirmar."* S\xE3o duas coisas, e junt\xE1-las numa s\xF3
2117
+ -- faria a fila esvaziar quando o dinheiro entra \u2014 mesmo que ningu\xE9m tenha
2118
+ -- olhado o que foi executado.
2119
+ --
2120
+ -- \`checkout_confirmed_at\` mora no \`metadata->'agenda'\` do agendamento, que \xE9
2121
+ -- onde o ETL do V1 o deixou: 332 atendimentos migrados j\xE1 o carregam ali.
2122
+ -- Escrever numa coluna nova faria o hist\xF3rico e o presente responderem em
2123
+ -- lugares diferentes.
2124
+ -- ============================================================================
2125
+
2126
+ CREATE OR REPLACE FUNCTION public.clinical_checkout_confirm(p_booking uuid)
2127
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2128
+ AS $fn$
2129
+ DECLARE
2130
+ a record; v_person uuid; v_aberto numeric := 0;
2131
+ BEGIN
2132
+ SELECT ap.id, ap.tenant_id, ap.status, ap.order_id, ap.metadata
2133
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2134
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2135
+
2136
+ -- Quem confere \xE9 quem est\xE1 no balc\xE3o. Diferente de iniciar o atendimento,
2137
+ -- aqui N\xC3O se exige ser o profissional do agendamento: conferir \xE9 da
2138
+ -- recep\xE7\xE3o, e a recep\xE7\xE3o n\xE3o atende ningu\xE9m.
2139
+ IF auth.uid() IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_sessao'); END IF;
2140
+ v_person := public.current_person_id();
2141
+
2142
+ IF a.status <> 'completed' THEN
2143
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_terminou');
2144
+ END IF;
2145
+ IF a.metadata -> 'agenda' ->> 'checkout_confirmed_at' IS NOT NULL THEN
2146
+ RETURN jsonb_build_object('ok', true, 'ja_confirmado', true);
2147
+ END IF;
2148
+
2149
+ -- S\xF3 o ramo \`agenda\` \xE9 reescrito, e ele \xE9 reescrito a partir do que j\xE1
2150
+ -- estava l\xE1: o \`||\` sobre o objeto inteiro apagaria o que o ETL do V1
2151
+ -- deixou no metadata do agendamento.
2152
+ UPDATE public.appointments ap
2153
+ SET metadata = coalesce(ap.metadata, '{}'::jsonb)
2154
+ || jsonb_build_object('agenda',
2155
+ coalesce(ap.metadata -> 'agenda', '{}'::jsonb)
2156
+ || jsonb_build_object(
2157
+ 'checkout_confirmed_at', to_jsonb(now()),
2158
+ 'checkout_confirmed_by', to_jsonb(coalesce(v_person::text, auth.uid()::text)))),
2159
+ updated_at = now()
2160
+ WHERE ap.id = p_booking;
2161
+
2162
+ -- O aviso cumpriu o papel dele. Deix\xE1-lo n\xE3o lido faria o sino cobrar uma
2163
+ -- confer\xEAncia que j\xE1 aconteceu.
2164
+ UPDATE public.notifications
2165
+ SET read = true
2166
+ WHERE subject_type = 'appointment' AND subject_id = p_booking AND NOT read;
2167
+
2168
+ SELECT coalesce(sum(i.total), 0) INTO v_aberto FROM public.items i WHERE i.order_id = a.order_id;
2169
+
2170
+ RETURN jsonb_build_object('ok', true, 'total', v_aberto);
2171
+ END $fn$;
2172
+
2173
+ GRANT EXECUTE ON FUNCTION public.clinical_checkout_confirm(uuid) TO authenticated;
2174
+ `;
2175
+ var MIGRATION_009_O_BOTAO_DE_COBRAR_COBRA = `-- ============================================================================
2176
+ -- O bot\xE3o de cobrar COBRA
2177
+ --
2178
+ -- SINTOMA, visto na tela: a conta do cliente mostra "Este atendimento ainda n\xE3o
2179
+ -- tem cobran\xE7a \xB7 **Gerar recebimento**", e o clique n\xE3o faz nada. Nenhum
2180
+ -- movimento nasce, nenhuma mensagem aparece. O bot\xE3o existe porque o painel o
2181
+ -- oferece, e quem o liga \xE9 o APP hospedeiro \u2014 que neste app n\xE3o liga.
2182
+ --
2183
+ -- Bot\xE3o morto \xE9 pior que bot\xE3o ausente: quem clica confia no bot\xE3o.
2184
+ --
2185
+ -- O caminho do dinheiro no V2 \xE9 um s\xF3: o receb\xEDvel \xE9 levantado quando o PEDIDO
2186
+ -- FECHA (\`orders_raise_receivable_on_close\`). Ent\xE3o cobrar \xE9 fechar o pedido do
2187
+ -- atendimento \u2014 e n\xE3o escrever fatura \xE0 m\xE3o, que \xE9 o que a regra do SDK pro\xEDbe.
2188
+ --
2189
+ -- \xC9 aqui, e n\xE3o na contrata\xE7\xE3o, porque o V1 separa as duas coisas: contratar
2190
+ -- cria a d\xEDvida; cobrar \xE9 da recep\xE7\xE3o, depois de conferir o que foi executado.
2191
+ -- ============================================================================
2192
+
2193
+ CREATE OR REPLACE FUNCTION public.clinical_checkout_charge(p_booking uuid)
2194
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2195
+ AS $fn$
2196
+ DECLARE
2197
+ a record; v_linhas int; v_total numeric; v_mov int;
2198
+ BEGIN
2199
+ SELECT ap.id, ap.tenant_id, ap.status, ap.order_id INTO a
2200
+ FROM public.appointments ap WHERE ap.id = p_booking;
2201
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2202
+ IF auth.uid() IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_sessao'); END IF;
2203
+
2204
+ IF a.order_id IS NULL THEN
2205
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_contratado');
2206
+ END IF;
2207
+
2208
+ SELECT count(*), coalesce(sum(i.total), 0) INTO v_linhas, v_total
2209
+ FROM public.items i WHERE i.order_id = a.order_id;
2210
+ IF v_linhas = 0 THEN
2211
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_contratado');
2212
+ END IF;
2213
+
2214
+ -- Idempotente: o pedido j\xE1 fechado devolve o que existe, sem levantar um
2215
+ -- segundo receb\xEDvel para a mesma visita.
2216
+ SELECT count(*) INTO v_mov FROM public.plg_financial_movements m WHERE m.invoice_id = a.order_id;
2217
+ IF v_mov > 0 THEN
2218
+ RETURN jsonb_build_object('ok', true, 'ja_cobrado', true, 'total', v_total);
2219
+ END IF;
2220
+
2221
+ UPDATE public.orders SET status = 'completed', updated_at = now()
2222
+ WHERE id = a.order_id AND status <> 'completed';
2223
+
2224
+ SELECT count(*) INTO v_mov FROM public.plg_financial_movements m WHERE m.invoice_id = a.order_id;
2225
+ RETURN jsonb_build_object('ok', true, 'total', v_total, 'movimentos', v_mov);
2226
+ END $fn$;
2227
+
2228
+ GRANT EXECUTE ON FUNCTION public.clinical_checkout_charge(uuid) TO authenticated;
2229
+ `;
2230
+ var MIGRATION_010_A_INDICACAO_VAI_PARA_O_FUNIL = `-- ============================================================================
2231
+ -- A INDICA\xC7\xC3O vai para o funil
2232
+ --
2233
+ -- O V1 escreve, na pr\xF3pria tela da avalia\xE7\xE3o: *"Indique procedimentos ou
2234
+ -- produtos para o cliente \u2014 eles alimentam as recomenda\xE7\xF5es do CRM e a etapa de
2235
+ -- or\xE7amento."* E a rede confirma: concluir a avalia\xE7\xE3o faz \`POST
2236
+ -- opportunity_recommendations\`.
2237
+ --
2238
+ -- **Indica\xE7\xE3o n\xE3o \xE9 venda.** \xC9 o que o profissional recomenda; virar cobran\xE7a \xE9
2239
+ -- outra decis\xE3o, do paciente, na contrata\xE7\xE3o. As duas palavras existem
2240
+ -- separadas no V1 porque separam dois momentos e duas responsabilidades.
2241
+ --
2242
+ -- No V2 a cesta do interesse pende de um NEG\xD3CIO (\`plg_crm_deal_interests.
2243
+ -- deal_id\` \xE9 NOT NULL). Ent\xE3o a avalia\xE7\xE3o abre \u2014 ou reaproveita \u2014 um neg\xF3cio do
2244
+ -- cliente e pendura ali o que foi indicado. \xC9 o mesmo desenho do V1, com o
2245
+ -- vocabul\xE1rio do V2: a indica\xE7\xE3o nasce no atendimento e vive no funil.
2246
+ -- ============================================================================
2247
+
2248
+ CREATE OR REPLACE FUNCTION public.clinical_evaluation_complete(p_booking uuid, p_lines jsonb DEFAULT '[]'::jsonb)
2249
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2250
+ AS $fn$
2251
+ DECLARE
2252
+ a record; v_person uuid; v_deal uuid; v_line jsonb; v_interest uuid;
2253
+ v_n int := 0; v_resolvidas int := 0; v_cliente text;
2254
+ BEGIN
2255
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status
2256
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2257
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2258
+
2259
+ v_person := public.current_person_id();
2260
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2261
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2262
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2263
+ END IF;
2264
+ IF a.status <> 'in_progress' THEN
2265
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
2266
+ END IF;
2267
+
2268
+ IF jsonb_typeof(p_lines) = 'array' AND jsonb_array_length(p_lines) > 0 THEN
2269
+ SELECT pe.name INTO v_cliente FROM public.people pe WHERE pe.id = a.party_id;
2270
+
2271
+ -- O neg\xF3cio do cliente. Reaproveita o que estiver aberto: duas avalia\xE7\xF5es
2272
+ -- no mesmo m\xEAs s\xE3o a mesma conversa, e abrir um funil por atendimento faria
2273
+ -- o quadro do CRM virar hist\xF3rico de agenda.
2274
+ SELECT d.id INTO v_deal FROM public.plg_crm_deals d
2275
+ WHERE d.tenant_id = a.tenant_id AND d.contact_id = a.party_id AND d.status = 'open'
2276
+ ORDER BY d.created_at DESC LIMIT 1;
2277
+
2278
+ IF v_deal IS NULL THEN
2279
+ INSERT INTO public.plg_crm_deals (tenant_id, unit_id, title, contact_id, assigned_to_id, metadata)
2280
+ VALUES (a.tenant_id, a.unit_id,
2281
+ 'Avalia\xE7\xE3o \u2014 ' || coalesce(v_cliente, 'cliente'), a.party_id, a.assignee_id,
2282
+ jsonb_build_object('source', 'clinical:avaliacao', 'booking_id', p_booking))
2283
+ RETURNING id INTO v_deal;
2284
+ END IF;
2285
+
2286
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
2287
+ LOOP
2288
+ -- Idempotente por (neg\xF3cio, produto): indicar duas vezes o mesmo
2289
+ -- procedimento \xE9 corrigir a quantidade, n\xE3o criar uma segunda indica\xE7\xE3o.
2290
+ SELECT i.id INTO v_interest FROM public.plg_crm_deal_interests i
2291
+ WHERE i.deal_id = v_deal AND i.product_id = (v_line ->> 'service_id')::uuid;
2292
+
2293
+ IF v_interest IS NULL THEN
2294
+ INSERT INTO public.plg_crm_deal_interests
2295
+ (tenant_id, unit_id, deal_id, product_id, quantity, origin, status, notes, metadata)
2296
+ VALUES (a.tenant_id, a.unit_id, v_deal, (v_line ->> 'service_id')::uuid,
2297
+ greatest(coalesce((v_line ->> 'quantity')::numeric, 1), 1),
2298
+ 'indication', 'open', nullif(v_line ->> 'notes', ''),
2299
+ jsonb_build_object('source', 'clinical:avaliacao', 'booking_id', p_booking))
2300
+ RETURNING id INTO v_interest;
2301
+ v_n := v_n + 1;
2302
+ ELSE
2303
+ UPDATE public.plg_crm_deal_interests
2304
+ SET quantity = greatest(coalesce((v_line ->> 'quantity')::numeric, 1), 1), updated_at = now()
2305
+ WHERE id = v_interest;
2306
+ END IF;
2307
+
2308
+ INSERT INTO public.plg_crm_deal_interest_appointments (tenant_id, unit_id, appointment_id, interest_id)
2309
+ SELECT a.tenant_id, a.unit_id, p_booking, v_interest
2310
+ WHERE NOT EXISTS (SELECT 1 FROM public.plg_crm_deal_interest_appointments x
2311
+ WHERE x.appointment_id = p_booking AND x.interest_id = v_interest);
2312
+ END LOOP;
2313
+ END IF;
2314
+
2315
+ -- A bola da avalia\xE7\xE3o fecha apontando a SESS\xC3O em que ela aconteceu. O V1
2316
+ -- resolve pelo v\xEDnculo com a execu\xE7\xE3o da avalia\xE7\xE3o; aqui a avalia\xE7\xE3o \xE9 este
2317
+ -- atendimento, e o que ela deixa registrado \xE9 o que ele indicou.
2318
+ UPDATE public.plg_clinical_requirements r
2319
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = r.session_id,
2320
+ resolved_via = 'attendance', resolved_at = now(), updated_at = now()
2321
+ WHERE r.booking_id = p_booking AND r.kind = 'evaluation' AND r.status = 'pending'
2322
+ AND r.frozen_at IS NULL AND r.session_id IS NOT NULL;
2323
+ GET DIAGNOSTICS v_resolvidas = ROW_COUNT;
2324
+
2325
+ RETURN jsonb_build_object('ok', true, 'deal_id', v_deal, 'indicacoes', v_n, 'bolas_fechadas', v_resolvidas);
2326
+ END $fn$;
2327
+
2328
+ GRANT EXECUTE ON FUNCTION public.clinical_evaluation_complete(uuid, jsonb) TO authenticated;
2329
+ `;
2330
+ var MIGRATION_011_INTERESSE_E_INDICACAO_NA_MESMA_LINHA = `-- ============================================================================
2331
+ -- INTERESSE e INDICA\xC7\xC3O na MESMA LINHA
2332
+ --
2333
+ -- O desfecho do desenho do V1, e a captura que o documenta mostra a linha
2334
+ -- inteira:
2335
+ --
2336
+ -- \xC1cido Hialur\xF4nico \u2014 R$ 999,00
2337
+ -- Interesse do cliente \xB7 Indicado pelo profissional
2338
+ --
2339
+ -- S\xE3o tr\xEAs palavras e tr\xEAs momentos: **interesse** \xE9 o que o cliente pediu ao
2340
+ -- marcar; **indica\xE7\xE3o** \xE9 o que o profissional recomendou na avalia\xE7\xE3o;
2341
+ -- **contrata\xE7\xE3o** \xE9 o que virou cobran\xE7a. O mesmo servi\xE7o pode carregar as duas
2342
+ -- primeiras \u2014 e a\xED ele aparece UMA vez, com as duas origens somadas no r\xF3tulo.
2343
+ --
2344
+ -- Duas linhas para o mesmo procedimento fariam a recep\xE7\xE3o cobrar duas vezes; e
2345
+ -- esconder a origem faria o paciente ouvir "o senhor pediu" sobre algo que foi
2346
+ -- recomendado. Por isso a fus\xE3o \xE9 do BANCO, e n\xE3o de cada tela que precisar
2347
+ -- dela.
2348
+ -- ============================================================================
2349
+
2350
+ CREATE OR REPLACE FUNCTION public.clinical_contract_candidates(p_booking uuid)
2351
+ RETURNS TABLE (
2352
+ service_id uuid,
2353
+ name text,
2354
+ price numeric,
2355
+ quantity numeric,
2356
+ from_interest boolean,
2357
+ from_indication boolean,
2358
+ already_contracted boolean
2359
+ ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'app'
2360
+ AS $fn$
2361
+ WITH alvo AS (
2362
+ SELECT ap.id, ap.tenant_id, ap.party_id, ap.order_id FROM public.appointments ap WHERE ap.id = p_booking
2363
+ ),
2364
+ -- O que o cliente pediu ao marcar.
2365
+ interesse AS (
2366
+ SELECT ai.service_id, max(ai.name) AS name, max(ai.price) AS price, count(*)::numeric AS qtd
2367
+ FROM public.appointment_items ai, alvo
2368
+ WHERE ai.booking_id = alvo.id AND ai.service_id IS NOT NULL
2369
+ GROUP BY ai.service_id
2370
+ ),
2371
+ -- O que o profissional recomendou \u2014 deste atendimento ou do neg\xF3cio aberto
2372
+ -- do cliente, que \xE9 onde a avalia\xE7\xE3o pendura o que indicou.
2373
+ indicacao AS (
2374
+ SELECT i.product_id AS service_id, max(i.quantity) AS qtd
2375
+ FROM public.plg_crm_deal_interests i
2376
+ JOIN public.plg_crm_deals d ON d.id = i.deal_id, alvo
2377
+ WHERE i.tenant_id = alvo.tenant_id AND i.status = 'open'
2378
+ AND (d.contact_id = alvo.party_id
2379
+ OR EXISTS (SELECT 1 FROM public.plg_crm_deal_interest_appointments x
2380
+ WHERE x.interest_id = i.id AND x.appointment_id = alvo.id))
2381
+ GROUP BY i.product_id
2382
+ ),
2383
+ -- O que j\xE1 virou cobran\xE7a nesta visita: aparece marcado, n\xE3o some \u2014 sumir
2384
+ -- faria a lista mentir sobre o que foi combinado.
2385
+ contratado AS (
2386
+ SELECT it.product_id AS service_id FROM public.items it, alvo
2387
+ WHERE alvo.order_id IS NOT NULL AND it.order_id = alvo.order_id AND it.product_id IS NOT NULL
2388
+ )
2389
+ SELECT
2390
+ coalesce(i.service_id, n.service_id) AS service_id,
2391
+ coalesce(i.name, p.name) AS name,
2392
+ coalesce(nullif(i.price, 0), p.price, 0) AS price,
2393
+ greatest(coalesce(i.qtd, 0), coalesce(n.qtd, 0), 1) AS quantity,
2394
+ i.service_id IS NOT NULL AS from_interest,
2395
+ n.service_id IS NOT NULL AS from_indication,
2396
+ EXISTS (SELECT 1 FROM contratado c WHERE c.service_id = coalesce(i.service_id, n.service_id)) AS already_contracted
2397
+ FROM interesse i
2398
+ FULL OUTER JOIN indicacao n ON n.service_id = i.service_id
2399
+ LEFT JOIN public.products p ON p.id = coalesce(i.service_id, n.service_id)
2400
+ ORDER BY 2;
2401
+ $fn$;
2402
+
2403
+ GRANT EXECUTE ON FUNCTION public.clinical_contract_candidates(uuid) TO authenticated;
2404
+ `;
2405
+ var MIGRATION_012_O_QUE_FOI_USADO_ESPERA_APROVACAO = `-- ============================================================================
2406
+ -- O QUE FOI USADO espera aprova\xE7\xE3o
2407
+ --
2408
+ -- A bola de produtos do V1, e a frase que a define: *"Ele vem tudo pra c\xE1
2409
+ -- primeiro. Vem pra aprova\xE7\xE3o de dispensa."* **Nada baixa aqui.** O consumo
2410
+ -- declarado pelo profissional cai na fila da Auditoria de Uso, e \xE9 l\xE1 que
2411
+ -- algu\xE9m confirma \u2014 ou corrige a quantidade lan\xE7ada errada.
2412
+ --
2413
+ -- Registrar consumo baixando estoque na hora seria transformar a mem\xF3ria de
2414
+ -- quem atende em movimento cont\xE1bil sem confer\xEAncia, que \xE9 exatamente o que a
2415
+ -- auditoria existe para evitar.
2416
+ --
2417
+ -- \`is_audited = false\` \xE9 o estado inicial, e \xE9 o que p\xF5e a linha na fila.
2418
+ -- ============================================================================
2419
+
2420
+ CREATE OR REPLACE FUNCTION public.clinical_products_record(p_booking uuid, p_lines jsonb DEFAULT '[]'::jsonb)
2421
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2422
+ AS $fn$
2423
+ DECLARE
2424
+ a record; v_person uuid; v_line jsonb; v_session uuid; v_n int := 0; v_marcadas int := 0;
2425
+ BEGIN
2426
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.assignee_id, ap.status
2427
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2428
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2429
+
2430
+ v_person := public.current_person_id();
2431
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2432
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2433
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2434
+ END IF;
2435
+ IF a.status <> 'in_progress' THEN
2436
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
2437
+ END IF;
2438
+
2439
+ FOR v_line IN SELECT * FROM jsonb_array_elements(coalesce(p_lines, '[]'::jsonb))
2440
+ LOOP
2441
+ -- A sess\xE3o do SERVI\xC7O que consumiu. O V1 desenha um bloco por servi\xE7o
2442
+ -- justamente porque a mesma seringa gasta em procedimentos diferentes, e
2443
+ -- juntar tudo num monte perde de qual execu\xE7\xE3o veio.
2444
+ SELECT cs.id INTO v_session FROM public.plg_clinical_sessions cs
2445
+ WHERE cs.booking_id = p_booking AND cs.service_id = (v_line ->> 'service_id')::uuid
2446
+ ORDER BY cs.created_at LIMIT 1;
2447
+ IF v_session IS NULL THEN CONTINUE; END IF;
2448
+
2449
+ INSERT INTO public.plg_clinical_session_products
2450
+ (tenant_id, unit_id, session_id, product_id, assignee_id, used_quantity, used_unit_id, executed_on, is_audited)
2451
+ SELECT a.tenant_id, a.unit_id, v_session, (v_line ->> 'product_id')::uuid, v_person,
2452
+ greatest(coalesce((v_line ->> 'quantity')::numeric, 1), 0),
2453
+ nullif(v_line ->> 'unit_id', '')::uuid, current_date, false
2454
+ WHERE NOT EXISTS (
2455
+ SELECT 1 FROM public.plg_clinical_session_products sp
2456
+ WHERE sp.session_id = v_session AND sp.product_id = (v_line ->> 'product_id')::uuid);
2457
+ IF FOUND THEN
2458
+ v_n := v_n + 1;
2459
+ ELSE
2460
+ UPDATE public.plg_clinical_session_products
2461
+ SET used_quantity = greatest(coalesce((v_line ->> 'quantity')::numeric, 1), 0), updated_at = now()
2462
+ WHERE session_id = v_session AND product_id = (v_line ->> 'product_id')::uuid AND NOT is_audited;
2463
+ END IF;
2464
+ END LOOP;
2465
+
2466
+ -- Conferido \xE9 conferido, mesmo que n\xE3o tenha sido usado nada: "n\xE3o usei
2467
+ -- produto neste procedimento" \xE9 uma resposta, e sem poder d\xE1-la a bola ficaria
2468
+ -- pendente para sempre em quem n\xE3o gasta insumo.
2469
+ UPDATE public.plg_clinical_sessions
2470
+ SET products_initialized = true, updated_at = now()
2471
+ WHERE booking_id = p_booking AND coalesce(products_initialized, false) = false;
2472
+ GET DIAGNOSTICS v_marcadas = ROW_COUNT;
2473
+
2474
+ RETURN jsonb_build_object(
2475
+ 'ok', true, 'lancados', v_n, 'sessoes_conferidas', v_marcadas,
2476
+ 'regua', public.clinical_requirements_refresh(p_booking));
2477
+ END $fn$;
2478
+
2479
+ GRANT EXECUTE ON FUNCTION public.clinical_products_record(uuid, jsonb) TO authenticated;
2480
+ `;
2481
+ var MIGRATION_013_O_QUE_COUBER_HOJE = `-- ============================================================================
2482
+ -- O QUE COUBER HOJE
2483
+ --
2484
+ -- *"Marque os servi\xE7os que ser\xE3o executados agora. Os demais ficam contratados
2485
+ -- para execu\xE7\xE3o em outro atendimento."* \xC9 o di\xE1logo que o V1 abre depois da
2486
+ -- contrata\xE7\xE3o, e ele existe por causa de uma frase da demonstra\xE7\xE3o: *"o labial
2487
+ -- veio com uma festa amanh\xE3, n\xE3o quero ficar com o l\xE1bio roxo"*.
2488
+ --
2489
+ -- \xC9 este passo que separa **contratado** de **executado** \u2014 e \xE9 dele que nasce
2490
+ -- o saldo *a executar* na conta do cliente. Sem ele, contratar tr\xEAs sess\xF5es
2491
+ -- significaria executar tr\xEAs sess\xF5es no mesmo dia, o que n\xE3o \xE9 como uma cl\xEDnica
2492
+ -- funciona.
2493
+ --
2494
+ -- O que N\xC3O se executa hoje n\xE3o vira pend\xEAncia do atendimento: fica contratado,
2495
+ -- pago ou a pagar, esperando outra data.
2496
+ -- ============================================================================
2497
+
2498
+ CREATE OR REPLACE FUNCTION public.clinical_execute_services(p_booking uuid, p_service_ids uuid[])
2499
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2500
+ AS $fn$
2501
+ DECLARE
2502
+ a record; v_person uuid; v_executadas int := 0; v_adiadas int := 0;
2503
+ BEGIN
2504
+ SELECT ap.id, ap.assignee_id, ap.status INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2505
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2506
+
2507
+ v_person := public.current_person_id();
2508
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2509
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2510
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2511
+ END IF;
2512
+ IF a.status <> 'in_progress' THEN
2513
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
2514
+ END IF;
2515
+
2516
+ UPDATE public.plg_clinical_sessions cs
2517
+ SET status = 'executed', ended_at = coalesce(cs.ended_at, now()), updated_at = now()
2518
+ WHERE cs.booking_id = p_booking
2519
+ AND cs.service_id = ANY (coalesce(p_service_ids, ARRAY[]::uuid[]))
2520
+ AND cs.status <> 'executed';
2521
+ GET DIAGNOSTICS v_executadas = ROW_COUNT;
2522
+
2523
+ -- O que ficou de fora volta a CONTRATADO \u2014 e n\xE3o a pendente: foi combinado e
2524
+ -- continua combinado, s\xF3 que para outra data. Desmarcar depois de ter
2525
+ -- executado por engano \xE9 o caso que esta linha resolve.
2526
+ UPDATE public.plg_clinical_sessions cs
2527
+ SET status = 'contracted', ended_at = NULL, updated_at = now()
2528
+ WHERE cs.booking_id = p_booking
2529
+ AND NOT (cs.service_id = ANY (coalesce(p_service_ids, ARRAY[]::uuid[])))
2530
+ AND cs.status = 'executed';
2531
+ GET DIAGNOSTICS v_adiadas = ROW_COUNT;
2532
+
2533
+ RETURN jsonb_build_object('ok', true, 'executadas', v_executadas, 'adiadas', v_adiadas);
2534
+ END $fn$;
2535
+
2536
+ GRANT EXECUTE ON FUNCTION public.clinical_execute_services(uuid, uuid[]) TO authenticated;
2537
+ `;
2538
+ var MIGRATION_014_O_PAGO_A_EXECUTAR_DEIXA_DE_SER_INVISIVEL = `-- ============================================================================
2539
+ -- O "PAGO A EXECUTAR" deixa de ser invis\xEDvel
2540
+ --
2541
+ -- O tipo \`ClientAccount\` do financeiro traz este coment\xE1rio, escrito quando o
2542
+ -- porte come\xE7ou:
2543
+ --
2544
+ -- "\`paidToExecute\` (o 'pago a executar' do V1) \xE9 o valor j\xE1 recebido de
2545
+ -- servi\xE7o ainda n\xE3o prestado: num sal\xE3o isso \xE9 PASSIVO, n\xE3o receita. Fica
2546
+ -- opcional porque o V2 ainda n\xE3o tem o eixo de EXECU\xC7\xC3O \u2014 modela pagamento e
2547
+ -- n\xE3o modela entrega \u2014, e um zero fixo ali mentiria sobre uma d\xEDvida."
2548
+ --
2549
+ -- O eixo passou a existir: \`plg_clinical_sessions.status\` separa **contratado**
2550
+ -- de **executado**, e a etapa "o que couber hoje" \xE9 quem move uma para a outra.
2551
+ -- Ent\xE3o a conta pode responder \u2014 e a regra de honestidade se inverte: agora
2552
+ -- OMITIR o n\xFAmero esconderia a d\xEDvida.
2553
+ --
2554
+ -- A vista \xE9 do plugin cl\xEDnico porque o dado \xE9 dele. O financeiro l\xEA se ela
2555
+ -- existir; onde o prontu\xE1rio n\xE3o est\xE1 instalado, a conta continua sem o cart\xE3o,
2556
+ -- que \xE9 a resposta certa para quem n\xE3o tem execu\xE7\xE3o para acompanhar.
2557
+ -- ============================================================================
2558
+
2559
+ CREATE OR REPLACE VIEW public.v_clinical_to_execute AS
2560
+ WITH sessao AS (
2561
+ SELECT
2562
+ cs.tenant_id,
2563
+ cs.party_id,
2564
+ cs.order_item_id,
2565
+ cs.status,
2566
+ i.order_id,
2567
+ coalesce(i.total, 0) AS item_total
2568
+ FROM public.plg_clinical_sessions cs
2569
+ JOIN public.items i ON i.id = cs.order_item_id
2570
+ WHERE cs.party_id IS NOT NULL
2571
+ AND cs.status IN ('contracted', 'quoted', 'in_service', 'scheduled', 'pending')
2572
+ ),
2573
+ -- Quanto do PEDIDO j\xE1 foi recebido, em propor\xE7\xE3o: o pagamento \xE9 por parcela e
2574
+ -- n\xE3o por item, ent\xE3o a fatia paga de cada item \xE9 a fatia paga do pedido.
2575
+ pago AS (
2576
+ SELECT m.invoice_id AS order_id,
2577
+ sum(coalesce(m.paid_amount, 0)) AS pago,
2578
+ nullif(sum(coalesce(m.amount, 0)), 0) AS total
2579
+ FROM public.plg_financial_movements m
2580
+ WHERE m.direction = 'credit'
2581
+ GROUP BY m.invoice_id
2582
+ )
2583
+ SELECT
2584
+ s.tenant_id,
2585
+ s.party_id,
2586
+ sum(s.item_total * coalesce(p.pago / p.total, 0))::numeric(14,2) AS paid_to_execute,
2587
+ sum(s.item_total * (1 - coalesce(p.pago / p.total, 0)))::numeric(14,2) AS open_to_execute,
2588
+ count(*)::int AS sessions_to_execute
2589
+ FROM sessao s
2590
+ LEFT JOIN pago p ON p.order_id = s.order_id
2591
+ GROUP BY s.tenant_id, s.party_id;
2592
+
2593
+ COMMENT ON VIEW public.v_clinical_to_execute IS
2594
+ 'O que o cliente j\xE1 contratou e ainda N\xC3O foi executado, separado em pago e em aberto. O "pago a executar" do V1 \xE9 passivo: dinheiro recebido de servi\xE7o que a casa ainda deve prestar.';
2595
+
2596
+ GRANT SELECT ON public.v_clinical_to_execute TO authenticated;
2597
+ `;
2598
+ var MIGRATION_015_A_REVISAO_APONTA_O_QUE_REVISA = `-- ============================================================================
2599
+ -- A REVIS\xC3O aponta O QUE ela revisa
2600
+ --
2601
+ -- Revis\xE3o \xE9 o retorno de um procedimento que j\xE1 foi feito \u2014 e ela s\xF3 significa
2602
+ -- alguma coisa se disser DE QUAL. Sem o v\xEDnculo, o hist\xF3rico do paciente fica
2603
+ -- com um "Revis\xE3o" solto, e ningu\xE9m consegue responder, meses depois, o que foi
2604
+ -- revisado nem se ainda estava no prazo.
2605
+ --
2606
+ -- Por isso a exig\xEAncia \`revision_link\` do V1 \xE9 a \xFAnica que trava as DUAS pontas:
2607
+ -- \`blocks_start\` e \`blocks_finish\`. N\xE3o se come\xE7a uma revis\xE3o sem saber do qu\xEA,
2608
+ -- e n\xE3o se encerra sem ter dito.
2609
+ --
2610
+ -- O modal do V1 mudou em 21/09 (\`2e1a119ef\`): passou a listar **todo servi\xE7o
2611
+ -- executado**, com o **prazo \xE0 vista**, em vez de s\xF3 os candidatos filtrados \u2014
2612
+ -- porque quem atende sabe reconhecer o procedimento na lista, e um filtro
2613
+ -- silencioso esconde justamente o caso fora do padr\xE3o.
2614
+ -- ============================================================================
2615
+
2616
+ CREATE OR REPLACE FUNCTION public.clinical_revision_candidates(p_booking uuid)
2617
+ RETURNS TABLE (
2618
+ session_id uuid,
2619
+ service_id uuid,
2620
+ service_name text,
2621
+ executed_on timestamptz,
2622
+ deadline_days integer,
2623
+ days_since integer,
2624
+ within_deadline boolean
2625
+ ) LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public', 'app'
2626
+ AS $fn$
2627
+ WITH alvo AS (
2628
+ SELECT ap.tenant_id, ap.party_id FROM public.appointments ap WHERE ap.id = p_booking
2629
+ )
2630
+ SELECT
2631
+ cs.id,
2632
+ cs.service_id,
2633
+ p.name,
2634
+ coalesce(cs.ended_at, cs.scheduled_for) AS executed_on,
2635
+ rev.deadline_days,
2636
+ (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date)::int AS days_since,
2637
+ -- Prazo ausente n\xE3o vira "fora do prazo": vira SEM prazo, e a tela diz isso.
2638
+ CASE WHEN rev.deadline_days IS NULL THEN NULL
2639
+ ELSE (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date) <= rev.deadline_days
2640
+ END AS within_deadline
2641
+ FROM public.plg_clinical_sessions cs
2642
+ JOIN alvo a ON a.tenant_id = cs.tenant_id AND a.party_id = cs.party_id
2643
+ LEFT JOIN public.products p ON p.id = cs.service_id
2644
+ LEFT JOIN LATERAL (
2645
+ SELECT r.deadline_days FROM public.plg_clinical_service_revisions r
2646
+ WHERE r.tenant_id = cs.tenant_id AND r.service_id = cs.service_id
2647
+ ORDER BY r.revision_number LIMIT 1
2648
+ ) rev ON true
2649
+ WHERE cs.status = 'executed'
2650
+ AND cs.booking_id IS DISTINCT FROM p_booking
2651
+ ORDER BY coalesce(cs.ended_at, cs.scheduled_for) DESC NULLS LAST
2652
+ LIMIT 50;
2653
+ $fn$;
2654
+
2655
+ CREATE OR REPLACE FUNCTION public.clinical_link_revision(p_session uuid, p_parent_session uuid)
2656
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2657
+ AS $fn$
2658
+ DECLARE
2659
+ s record; v_person uuid; v_booking uuid;
2660
+ BEGIN
2661
+ SELECT cs.id, cs.booking_id, cs.tenant_id, cs.party_id INTO s
2662
+ FROM public.plg_clinical_sessions cs WHERE cs.id = p_session;
2663
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2664
+
2665
+ v_person := public.current_person_id();
2666
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2667
+
2668
+ -- O original tem de ser do MESMO paciente. Revisar o procedimento de outra
2669
+ -- pessoa n\xE3o \xE9 engano de digita\xE7\xE3o: \xE9 prontu\xE1rio trocado.
2670
+ IF NOT EXISTS (
2671
+ SELECT 1 FROM public.plg_clinical_sessions p
2672
+ WHERE p.id = p_parent_session AND p.party_id = s.party_id AND p.tenant_id = s.tenant_id
2673
+ ) THEN
2674
+ RETURN jsonb_build_object('ok', false, 'reason', 'original_de_outro_paciente');
2675
+ END IF;
2676
+
2677
+ UPDATE public.plg_clinical_sessions
2678
+ SET parent_session_id = p_parent_session, updated_at = now()
2679
+ WHERE id = p_session;
2680
+
2681
+ v_booking := s.booking_id;
2682
+ RETURN jsonb_build_object('ok', true, 'regua', public.clinical_requirements_refresh(v_booking));
2683
+ END $fn$;
2684
+
2685
+ GRANT EXECUTE ON FUNCTION public.clinical_revision_candidates(uuid) TO authenticated;
2686
+ GRANT EXECUTE ON FUNCTION public.clinical_link_revision(uuid, uuid) TO authenticated;
2687
+ `;
2688
+ var MIGRATION_016_A_COMPRA_ANTECIPADA_NAO_TEM_AGENDAMENTO_ATRAS = `-- ============================================================================
2689
+ -- A COMPRA ANTECIPADA n\xE3o tem agendamento atr\xE1s
2690
+ --
2691
+ -- O caso que a demonstra\xE7\xE3o do V1 narra: *"T\xE1 na promo\xE7\xE3o, vou comprar esse
2692
+ -- Botox e deixar guardado."* A recep\xE7\xE3o abre **Contas a Receber \u2192 Inserir**,
2693
+ -- escolhe o servi\xE7o, aplica o desconto combinado e recebe \u2014 **sem agendamento,
2694
+ -- sem contrato e sem proposta**. O V1 \xE9 expl\xEDcito: *"N\xE3o precisa de contrato?
2695
+ -- N\xE3o. N\xE3o precisa de proposta? N\xE3o."*
2696
+ --
2697
+ -- O efeito aparece na CONTA DA CLIENTE: um item **a executar**, pago, esperando
2698
+ -- o dia. \xC9 por isso que ele \xE9 passivo \u2014 a casa recebeu e ainda deve prestar \u2014,
2699
+ -- e \xE9 por isso que ele precisa nascer como SESS\xC3O: sem sess\xE3o, "a executar" n\xE3o
2700
+ -- existe como estado, e o dinheiro entraria como receita de um servi\xE7o que
2701
+ -- ningu\xE9m fez.
2702
+ --
2703
+ -- Sess\xE3o sem \`booking_id\` \xE9 o que distingue esta compra de um atendimento: ela
2704
+ -- ainda n\xE3o tem dia. Ganha um quando for agendada.
2705
+ -- ============================================================================
2706
+
2707
+ CREATE OR REPLACE FUNCTION public.clinical_prepay_services(p_party uuid, p_lines jsonb)
2708
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2709
+ AS $fn$
2710
+ DECLARE
2711
+ v_tenant uuid; v_unit uuid; v_order uuid; v_line jsonb;
2712
+ v_rpc_lines jsonb := '[]'::jsonb; v_idx int := 0; v_sessoes int := 0; v_total numeric;
2713
+ BEGIN
2714
+ IF auth.uid() IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_sessao'); END IF;
2715
+ v_tenant := app.current_tenant_id();
2716
+ IF v_tenant IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_tenant'); END IF;
2717
+ IF NOT EXISTS (SELECT 1 FROM public.people p WHERE p.id = p_party AND p.tenant_id = v_tenant) THEN
2718
+ RETURN jsonb_build_object('ok', false, 'reason', 'cliente_nao_encontrado');
2719
+ END IF;
2720
+ IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN
2721
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_escolhido');
2722
+ END IF;
2723
+
2724
+ v_unit := app.requested_unit();
2725
+
2726
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
2727
+ LOOP
2728
+ v_rpc_lines := v_rpc_lines || jsonb_build_object(
2729
+ 'product_id', v_line ->> 'service_id',
2730
+ 'name', v_line ->> 'name',
2731
+ 'quantity', greatest(coalesce((v_line ->> 'quantity')::numeric, 1), 1),
2732
+ 'unit_price', greatest(coalesce((v_line ->> 'unit_price')::numeric, 0), 0),
2733
+ 'sort_order', v_idx);
2734
+ v_idx := v_idx + 1;
2735
+ END LOOP;
2736
+
2737
+ v_order := public.order_create(
2738
+ p_kind => 'sale',
2739
+ p_lines => v_rpc_lines,
2740
+ p_party_id => p_party,
2741
+ p_unit_id => v_unit,
2742
+ -- Nasce CONFIRMADO e fecha logo abaixo, em dois tempos \u2014 e isso n\xE3o \xE9
2743
+ -- cerim\xF4nia: o gatilho que levanta o receb\xEDvel \xE9 \`AFTER UPDATE OF status\`,
2744
+ -- ent\xE3o um pedido criado j\xE1 \`completed\` **vende sem cobrar**. Medido: a
2745
+ -- primeira vers\xE3o desta fun\xE7\xE3o criou a compra fechada e a conta ficou com
2746
+ -- "a executar" e ZERO cobran\xE7a.
2747
+ -- (\`scheduled\` nem \xE9 aceito para \`sale\`: o contrato de estados do pedido
2748
+ -- permite draft, confirmed, completed e cancelled.)
2749
+ p_status => 'confirmed',
2750
+ p_metadata => jsonb_build_object('source', 'clinical:compra-antecipada'),
2751
+ p_idempotency_key => NULL);
2752
+
2753
+ -- Uma sess\xE3o por item, SEM dia. \xC9 ela que sustenta o "a executar" na conta \u2014
2754
+ -- e o que permite, quando o dia chegar, amarrar a execu\xE7\xE3o ao que j\xE1 foi pago.
2755
+ INSERT INTO public.plg_clinical_sessions
2756
+ (tenant_id, unit_id, status, party_id, service_id, order_item_id)
2757
+ SELECT v_tenant, v_unit, 'contracted', p_party, i.product_id, i.id
2758
+ FROM public.items i
2759
+ WHERE i.order_id = v_order AND i.product_id IS NOT NULL;
2760
+ GET DIAGNOSTICS v_sessoes = ROW_COUNT;
2761
+
2762
+ -- O fechamento, agora como UPDATE: \xE9 ele que dispara
2763
+ -- \`orders_raise_receivable_on_close\` e faz nascer a conta a receber.
2764
+ UPDATE public.orders SET status = 'completed', updated_at = now() WHERE id = v_order;
2765
+
2766
+ SELECT coalesce(sum(i.total), 0) INTO v_total FROM public.items i WHERE i.order_id = v_order;
2767
+
2768
+ RETURN jsonb_build_object('ok', true, 'order_id', v_order, 'sessoes', v_sessoes, 'total', v_total);
2769
+ END $fn$;
2770
+
2771
+ GRANT EXECUTE ON FUNCTION public.clinical_prepay_services(uuid, jsonb) TO authenticated;
2772
+ `;
2773
+ var MIGRATION_017_O_QUE_JA_FOI_PAGO_NAO_SE_COBRA_DE_NOVO = `-- ============================================================================
2774
+ -- O QUE J\xC1 FOI PAGO n\xE3o se cobra de novo
2775
+ --
2776
+ -- A compra antecipada deixa uma sess\xE3o **sem dia**: o Botox comprado na
2777
+ -- promo\xE7\xE3o, esperando. Quando o dia chega, o atendimento come\xE7ava criando uma
2778
+ -- sess\xE3o NOVA para o mesmo servi\xE7o \u2014 e a\xED a cliente tinha duas: a que pagou e a
2779
+ -- que acabou de ser aberta. A primeira ficava "a executar" para sempre, e a
2780
+ -- segunda entrava na contrata\xE7\xE3o como se nada tivesse sido pago.
2781
+ --
2782
+ -- \xC9 a mesma pessoa, o mesmo servi\xE7o e o mesmo dinheiro. Ent\xE3o o in\xEDcio do
2783
+ -- atendimento ADOTA a sess\xE3o que j\xE1 existe: ela ganha o dia, o profissional e o
2784
+ -- item do agendamento, e deixa de pender na conta.
2785
+ --
2786
+ -- A escolha \xE9 a mais ANTIGA: quem comprou duas vezes executa primeiro o que
2787
+ -- comprou primeiro \u2014 e \xE9 isso que faz o saldo andar em vez de envelhecer.
2788
+ -- ============================================================================
2789
+
2790
+ CREATE OR REPLACE FUNCTION public.clinical_attendance_start(p_booking uuid)
2791
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2792
+ AS $fn$
2793
+ DECLARE
2794
+ a record; v_person uuid; v_created int := 0; v_adotadas int := 0;
2795
+ v_item record; v_refresh jsonb; v_antiga uuid;
2796
+ BEGIN
2797
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.starts_at
2798
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2799
+ IF NOT FOUND THEN
2800
+ RETURN jsonb_build_object('ok', false, 'reason', 'not_found');
2801
+ END IF;
2802
+
2803
+ v_person := public.current_person_id();
2804
+ IF v_person IS NULL THEN
2805
+ RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional');
2806
+ END IF;
2807
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2808
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2809
+ END IF;
2810
+ IF a.starts_at::date <> (now() AT TIME ZONE 'America/Sao_Paulo')::date THEN
2811
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_de_hoje');
2812
+ END IF;
2813
+ IF a.status IN ('completed', 'cancelled', 'no_show') THEN
2814
+ RETURN jsonb_build_object('ok', false, 'reason', 'ja_encerrado', 'status', a.status);
2815
+ END IF;
2816
+
2817
+ FOR v_item IN
2818
+ SELECT ai.id, ai.service_id, ai.assignee_id
2819
+ FROM public.appointment_items ai
2820
+ WHERE ai.booking_id = p_booking
2821
+ LOOP
2822
+ -- J\xE1 existe sess\xE3o para este item? (dois toques, duas pessoas ao mesmo tempo)
2823
+ CONTINUE WHEN EXISTS (
2824
+ SELECT 1 FROM public.plg_clinical_sessions cs
2825
+ WHERE cs.booking_id = p_booking AND cs.booking_item_id = v_item.id);
2826
+
2827
+ -- O QUE FOI COMPRADO ANTES E AINDA N\xC3O SAIU: adota em vez de criar.
2828
+ SELECT cs.id INTO v_antiga
2829
+ FROM public.plg_clinical_sessions cs
2830
+ WHERE cs.tenant_id = a.tenant_id
2831
+ AND cs.party_id = a.party_id
2832
+ AND cs.service_id = v_item.service_id
2833
+ AND cs.booking_id IS NULL
2834
+ AND cs.status IN ('contracted', 'quoted', 'pending')
2835
+ ORDER BY cs.created_at
2836
+ LIMIT 1;
2837
+
2838
+ IF v_antiga IS NOT NULL THEN
2839
+ UPDATE public.plg_clinical_sessions
2840
+ SET booking_id = p_booking,
2841
+ booking_item_id = v_item.id,
2842
+ unit_id = coalesce(a.unit_id, unit_id),
2843
+ assignee_id = coalesce(v_item.assignee_id, a.assignee_id),
2844
+ performed_by_id = v_person,
2845
+ status = 'in_service',
2846
+ scheduled_for = a.starts_at,
2847
+ started_at = coalesce(started_at, now()),
2848
+ updated_at = now()
2849
+ WHERE id = v_antiga;
2850
+ v_adotadas := v_adotadas + 1;
2851
+ ELSE
2852
+ INSERT INTO public.plg_clinical_sessions (
2853
+ tenant_id, unit_id, status, booking_id, booking_item_id, party_id,
2854
+ assignee_id, performed_by_id, service_id, scheduled_for, started_at)
2855
+ VALUES (a.tenant_id, a.unit_id, 'in_service', a.id, v_item.id, a.party_id,
2856
+ coalesce(v_item.assignee_id, a.assignee_id), v_person, v_item.service_id,
2857
+ a.starts_at, now());
2858
+ v_created := v_created + 1;
2859
+ END IF;
2860
+ END LOOP;
2861
+
2862
+ UPDATE public.appointments
2863
+ SET status = 'in_progress', updated_at = now()
2864
+ WHERE id = p_booking AND status <> 'in_progress';
2865
+
2866
+ v_refresh := public.clinical_requirements_refresh(p_booking);
2867
+
2868
+ RETURN jsonb_build_object(
2869
+ 'ok', true,
2870
+ 'sessoes_criadas', v_created,
2871
+ 'sessoes_adotadas', v_adotadas,
2872
+ 'sessoes', (SELECT count(*) FROM public.plg_clinical_sessions WHERE booking_id = p_booking),
2873
+ 'regua', v_refresh);
2874
+ END $fn$;
2875
+
2876
+ GRANT EXECUTE ON FUNCTION public.clinical_attendance_start(uuid) TO authenticated;
2877
+ `;
2878
+ var MIGRATION_018_O_QUE_FOI_CONTRATADO_VIRA_SESSAO = `-- O que foi contratado vira SESS\xC3O, mesmo sem agendamento atr\xE1s dele.
2879
+ --
2880
+ -- A 006 s\xF3 LIGAVA sess\xF5es j\xE1 existentes \xE0s linhas do pedido. Servi\xE7o indicado
2881
+ -- na avalia\xE7\xE3o e contratado na hora n\xE3o tem sess\xE3o nenhuma para ligar: a conta
2882
+ -- ficava com "Botox Full Face \xB7 2 \xB7 R$ 2.798,00" e o prontu\xE1rio n\xE3o sabia que
2883
+ -- havia dois Botox a executar. O dinheiro entrava e a execu\xE7\xE3o sumia \u2014 nem na
2884
+ -- r\xE9gua, nem em \`v_clinical_to_execute\`, nem no di\xE1logo de "Executar".
2885
+ --
2886
+ -- Uma sess\xE3o POR UNIDADE, porque \xE9 assim que a execu\xE7\xE3o acontece: dois Botox
2887
+ -- contratados s\xE3o duas idas \xE0 cadeira, e executar um hoje n\xE3o pode fechar o
2888
+ -- outro. O \`booking_id\` \xE9 o do atendimento onde foi contratado \u2014 quem n\xE3o for
2889
+ -- executado hoje continua \`contracted\`, que \xE9 o estado de "a executar".
2890
+
2891
+ CREATE OR REPLACE FUNCTION public.clinical_sessions_for_order(p_booking uuid, p_order uuid)
2892
+ RETURNS integer
2893
+ LANGUAGE plpgsql
2894
+ SECURITY DEFINER
2895
+ SET search_path TO ''
2896
+ AS $fn$
2897
+ DECLARE
2898
+ a public.appointments%ROWTYPE;
2899
+ v_item record;
2900
+ v_faltam integer;
2901
+ v_k integer;
2902
+ v_total integer := 0;
2903
+ BEGIN
2904
+ SELECT * INTO a FROM public.appointments WHERE id = p_booking;
2905
+ IF NOT FOUND THEN RETURN 0; END IF;
2906
+
2907
+ FOR v_item IN
2908
+ SELECT i.id, i.product_id, i.quantity,
2909
+ (SELECT count(*) FROM public.plg_clinical_sessions s WHERE s.order_item_id = i.id) AS ja
2910
+ FROM public.items i
2911
+ WHERE i.order_id = p_order
2912
+ AND i.product_id IS NOT NULL
2913
+ LOOP
2914
+ -- Servi\xE7o, n\xE3o produto: o que se executa numa cadeira.
2915
+ CONTINUE WHEN NOT EXISTS (
2916
+ SELECT 1 FROM public.products p
2917
+ WHERE p.id = v_item.product_id AND p.kind = 'service');
2918
+
2919
+ v_faltam := greatest(0, ceil(coalesce(v_item.quantity, 1))::integer - v_item.ja::integer);
2920
+ FOR v_k IN 1 .. v_faltam LOOP
2921
+ INSERT INTO public.plg_clinical_sessions
2922
+ (tenant_id, unit_id, status, booking_id, party_id, assignee_id, service_id, order_item_id, metadata)
2923
+ VALUES
2924
+ (a.tenant_id, a.unit_id, 'contracted', p_booking, a.party_id, a.assignee_id,
2925
+ v_item.product_id, v_item.id,
2926
+ jsonb_build_object('source', 'clinical_contract_services'));
2927
+ v_total := v_total + 1;
2928
+ END LOOP;
2929
+ END LOOP;
2930
+
2931
+ RETURN v_total;
2932
+ END $fn$;
2933
+
2934
+ GRANT EXECUTE ON FUNCTION public.clinical_sessions_for_order(uuid, uuid) TO authenticated;
2935
+
2936
+
2937
+ CREATE OR REPLACE FUNCTION public.clinical_contract_services(p_booking uuid, p_lines jsonb)
2938
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
2939
+ AS $fn$
2940
+ DECLARE
2941
+ a record; v_person uuid; v_order uuid; v_line jsonb; v_item uuid; v_n int := 0;
2942
+ v_rpc_lines jsonb := '[]'::jsonb; v_idx int := 0;
2943
+ BEGIN
2944
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.order_id
2945
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
2946
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
2947
+
2948
+ v_person := public.current_person_id();
2949
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
2950
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
2951
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
2952
+ END IF;
2953
+ IF a.status <> 'in_progress' THEN
2954
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
2955
+ END IF;
2956
+ IF jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN
2957
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_marcado');
2958
+ END IF;
2959
+
2960
+ -- As linhas, no formato que \`order_create\` j\xE1 entende.
2961
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
2962
+ LOOP
2963
+ v_rpc_lines := v_rpc_lines || jsonb_build_object(
2964
+ 'product_id', v_line ->> 'service_id',
2965
+ 'name', v_line ->> 'name',
2966
+ 'quantity', coalesce((v_line ->> 'quantity')::numeric, 1),
2967
+ 'unit_price', coalesce((v_line ->> 'unit_price')::numeric, 0),
2968
+ 'sort_order', v_idx);
2969
+ v_idx := v_idx + 1;
2970
+ END LOOP;
2971
+
2972
+ -- O pedido do atendimento. \`idempotency_key\` pelo agendamento: dois toques
2973
+ -- no "Contratar" n\xE3o abrem duas contas para a mesma visita.
2974
+ IF a.order_id IS NULL THEN
2975
+ v_order := public.order_create(
2976
+ p_kind => 'appointment',
2977
+ p_lines => v_rpc_lines,
2978
+ p_party_id => a.party_id,
2979
+ p_unit_id => a.unit_id,
2980
+ p_status => 'scheduled',
2981
+ p_assignee_id => a.assignee_id,
2982
+ p_metadata => jsonb_build_object('agenda', jsonb_build_object('booking_id', p_booking), 'source', 'clinical:contratacao'),
2983
+ p_idempotency_key => 'clinical-contract:' || p_booking::text);
2984
+ UPDATE public.appointments SET order_id = v_order, updated_at = now() WHERE id = p_booking;
2985
+ ELSE
2986
+ -- Pedido j\xE1 existe (a agenda abriu na marca\xE7\xE3o): acrescenta o que falta.
2987
+ v_order := a.order_id;
2988
+ v_idx := coalesce((SELECT max(i.sort_order) + 1 FROM public.items i WHERE i.order_id = v_order), 0);
2989
+ FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines)
2990
+ LOOP
2991
+ INSERT INTO public.items (tenant_id, unit_id, order_id, product_id, name, quantity, unit_price, total, sort_order)
2992
+ VALUES (a.tenant_id, a.unit_id, v_order, (v_line ->> 'service_id')::uuid, v_line ->> 'name',
2993
+ coalesce((v_line ->> 'quantity')::numeric, 1),
2994
+ coalesce((v_line ->> 'unit_price')::numeric, 0),
2995
+ coalesce((v_line ->> 'quantity')::numeric, 1) * coalesce((v_line ->> 'unit_price')::numeric, 0),
2996
+ v_idx);
2997
+ v_idx := v_idx + 1;
2998
+ END LOOP;
2999
+ END IF;
3000
+
3001
+ -- Cada sess\xE3o passa a apontar a SUA linha. Sem isto o pedido cobra e o
3002
+ -- atendimento n\xE3o sabe o que executou \u2014 e a bola "Conta do atendimento"
3003
+ -- nunca fecha, porque \xE9 exatamente este v\xEDnculo que ela procura.
3004
+ UPDATE public.plg_clinical_sessions cs
3005
+ SET order_item_id = i.id,
3006
+ status = CASE WHEN cs.status IN ('quoted', 'pending', 'scheduled') THEN 'contracted' ELSE cs.status END,
3007
+ updated_at = now()
3008
+ FROM public.items i
3009
+ WHERE cs.booking_id = p_booking
3010
+ AND cs.order_item_id IS NULL
3011
+ AND i.order_id = v_order
3012
+ AND i.product_id = cs.service_id
3013
+ AND NOT EXISTS (SELECT 1 FROM public.plg_clinical_sessions o
3014
+ WHERE o.order_item_id = i.id AND o.id <> cs.id);
3015
+ GET DIAGNOSTICS v_n = ROW_COUNT;
3016
+
3017
+ -- O que foi contratado e n\xE3o tinha sess\xE3o ganha a sua (018).
3018
+ PERFORM public.clinical_sessions_for_order(p_booking, v_order);
3019
+
3020
+ RETURN jsonb_build_object(
3021
+ 'ok', true, 'order_id', v_order, 'sessoes_ligadas', v_n,
3022
+ 'total', (SELECT coalesce(sum(i.total), 0) FROM public.items i WHERE i.order_id = v_order),
3023
+ 'regua', public.clinical_requirements_refresh(p_booking));
3024
+ END $fn$;
3025
+
3026
+ GRANT EXECUTE ON FUNCTION public.clinical_contract_services(uuid, jsonb) TO authenticated;
3027
+ `;
3028
+ var MIGRATION_019_ADIAR_E_UMA_ESCOLHA = `-- Adiar \xE9 uma escolha, e encerrar n\xE3o pode desfaz\xEA-la.
3029
+ --
3030
+ -- O "Executar neste atendimento" existe para dizer o que sai HOJE; o resto
3031
+ -- fica contratado para outro dia. Mas o \`Finalizar\` marcava como executada
3032
+ -- TODA sess\xE3o do agendamento \u2014 inclusive a que acabara de ser adiada. O
3033
+ -- servi\xE7o sumia do "a executar" da conta e a cliente ficava com um
3034
+ -- procedimento pago que ningu\xE9m fez, e que o sistema jurava ter feito.
3035
+ --
3036
+ -- O aviso de encerramento tinha o mesmo v\xEDcio: cobrava por servi\xE7os que a
3037
+ -- pessoa tinha acabado de dizer que ficariam para depois.
3038
+ --
3039
+ -- Agora as duas coisas olham para \`in_service\` \u2014 o que entrou na sala. O que
3040
+ -- est\xE1 em \`contracted\` \xE9 trabalho futuro e atravessa o encerramento intacto.
3041
+
3042
+ CREATE OR REPLACE FUNCTION public.clinical_attendance_finish(p_booking uuid, p_force boolean DEFAULT false)
3043
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
3044
+ AS $fn$
3045
+ DECLARE
3046
+ a record; v_person uuid; v_bloqueia jsonb; v_nao_executados jsonb;
3047
+ v_avisados int := 0; v_total numeric := 0; v_cliente text;
3048
+ BEGIN
3049
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status, ap.order_id
3050
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
3051
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
3052
+
3053
+ v_person := public.current_person_id();
3054
+ IF v_person IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional'); END IF;
3055
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
3056
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
3057
+ END IF;
3058
+ IF a.status <> 'in_progress' THEN
3059
+ RETURN jsonb_build_object('ok', false, 'reason', 'atendimento_nao_esta_em_curso');
3060
+ END IF;
3061
+
3062
+ -- A trava de verdade: exig\xEAncia que impede TERMINAR.
3063
+ SELECT coalesce(jsonb_agg(jsonb_build_object('kind', r.kind, 'service_id', r.service_id)), '[]'::jsonb)
3064
+ INTO v_bloqueia
3065
+ FROM public.plg_clinical_requirements r
3066
+ WHERE r.booking_id = p_booking AND r.blocks_finish AND r.status = 'pending';
3067
+ IF jsonb_array_length(v_bloqueia) > 0 THEN
3068
+ RETURN jsonb_build_object('ok', false, 'reason', 'exigencia_bloqueia', 'itens', v_bloqueia);
3069
+ END IF;
3070
+
3071
+ -- O aviso que n\xE3o trava: o que ENTROU NA SALA e n\xE3o foi confirmado.
3072
+ --
3073
+ -- "Fica para outra data" \xE9 uma escolha, n\xE3o um esquecimento: sess\xE3o em
3074
+ -- \`contracted\` \xE9 trabalho futuro, j\xE1 na conta, e n\xE3o tem por que ser
3075
+ -- cobrada no aviso nem executada \xE0 for\xE7a ao encerrar.
3076
+ SELECT coalesce(jsonb_agg(p.name ORDER BY p.name), '[]'::jsonb) INTO v_nao_executados
3077
+ FROM public.plg_clinical_sessions cs
3078
+ JOIN public.products p ON p.id = cs.service_id
3079
+ WHERE cs.booking_id = p_booking AND cs.status = 'in_service';
3080
+ IF jsonb_array_length(v_nao_executados) > 0 AND NOT coalesce(p_force, false) THEN
3081
+ RETURN jsonb_build_object('ok', false, 'reason', 'servicos_nao_executados', 'itens', v_nao_executados);
3082
+ END IF;
3083
+
3084
+ -- "Finalizar agora registra estes servi\xE7os como executados" \u2014 texto do V1,
3085
+ -- e vale para os que estavam EM EXECU\xC7\xC3O. O que foi adiado continua
3086
+ -- contratado: marc\xE1-lo executado sumiria com ele do "a executar" da conta
3087
+ -- sem que a cliente tivesse recebido nada.
3088
+ UPDATE public.plg_clinical_sessions
3089
+ SET status = 'executed', ended_at = coalesce(ended_at, now()), updated_at = now()
3090
+ WHERE booking_id = p_booking AND status = 'in_service';
3091
+
3092
+ UPDATE public.appointments
3093
+ SET status = 'completed', updated_at = now()
3094
+ WHERE id = p_booking;
3095
+
3096
+ -- Congela a trilha: depois de encerrado, a r\xE9gua n\xE3o se reapura.
3097
+ PERFORM public.clinical_requirements_refresh(p_booking, true);
3098
+
3099
+ SELECT coalesce(sum(i.total), 0) INTO v_total FROM public.items i WHERE i.order_id = a.order_id;
3100
+ SELECT pe.name INTO v_cliente FROM public.people pe WHERE pe.id = a.party_id;
3101
+
3102
+ -- O BALC\xC3O ACENDE.
3103
+ --
3104
+ -- Quem recebe: a recep\xE7\xE3o da conta. Onde n\xE3o houver ningu\xE9m com o papel de
3105
+ -- recep\xE7\xE3o \u2014 e \xE9 o caso do tenant migrado, com 0 membros nele \u2014, cai para
3106
+ -- gerente e dono. Um aviso que n\xE3o chega a ningu\xE9m deixaria a conta parada
3107
+ -- sem que nada na tela dissesse por qu\xEA.
3108
+ WITH alvo AS (
3109
+ SELECT m.user_id
3110
+ FROM app.memberships m
3111
+ JOIN app.roles r ON r.id = m.role_id
3112
+ WHERE m.tenant_id = a.tenant_id AND coalesce(m.active, true)
3113
+ AND r.key IN ('recepcao', 'reception')
3114
+ UNION ALL
3115
+ SELECT m.user_id
3116
+ FROM app.memberships m
3117
+ JOIN app.roles r ON r.id = m.role_id
3118
+ WHERE m.tenant_id = a.tenant_id AND coalesce(m.active, true)
3119
+ AND r.key IN ('owner', 'manager')
3120
+ AND NOT EXISTS (
3121
+ SELECT 1 FROM app.memberships m2 JOIN app.roles r2 ON r2.id = m2.role_id
3122
+ WHERE m2.tenant_id = a.tenant_id AND coalesce(m2.active, true)
3123
+ AND r2.key IN ('recepcao', 'reception'))
3124
+ )
3125
+ INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id)
3126
+ SELECT a.tenant_id, alvo.user_id,
3127
+ 'Checkout pendente',
3128
+ coalesce(v_cliente, 'Cliente') || ' terminou o atendimento'
3129
+ -- O dinheiro escrito como o Brasil escreve. \`to_char\` usa o
3130
+ -- \`lc_numeric\` do BANCO para o grupo e a v\xEDrgula, e o cluster est\xE1
3131
+ -- em C: sa\xEDa "R$ 999.00" no aviso de uma recep\xE7\xE3o brasileira. A
3132
+ -- troca \xE9 feita \xE0 m\xE3o para n\xE3o depender de locale nenhum.
3133
+ || CASE WHEN v_total > 0 THEN ' \xB7 R$ ' ||
3134
+ replace(replace(replace(to_char(v_total, 'FM999G999G999D00'), ',', '\xA7'), '.', ','), '\xA7', '.')
3135
+ ELSE '' END,
3136
+ 'checkout_pendente', '#/checkin', 'appointment', p_booking
3137
+ FROM alvo
3138
+ ON CONFLICT (user_id, subject_type, subject_id, type) WHERE subject_id IS NOT NULL DO NOTHING;
3139
+ GET DIAGNOSTICS v_avisados = ROW_COUNT;
3140
+
3141
+ RETURN jsonb_build_object(
3142
+ 'ok', true, 'avisados', v_avisados, 'total', v_total,
3143
+ 'executados', (SELECT count(*) FROM public.plg_clinical_sessions WHERE booking_id = p_booking AND status = 'executed'));
3144
+ END $fn$;
3145
+
3146
+ GRANT EXECUTE ON FUNCTION public.clinical_attendance_finish(uuid, boolean) TO authenticated;
3147
+ `;
3148
+ var MIGRATION_020_COBRAR_UM_AGENDAMENTO_SEM_PEDIDO_LEVANTA_O_PEDIDO = `-- ============================================================================
3149
+ -- Cobrar um agendamento SEM pedido levanta o pedido primeiro
3150
+ --
3151
+ -- SINTOMA, visto na tela (23-09, Juliane): a aba Financeiro acende o vermelho
3152
+ -- do V1 ("atendimento de hoje sem cobran\xE7a levantada"), mas a conta n\xE3o
3153
+ -- oferece caminho nenhum \u2014 o agendamento veio da MIGRA\xC7\xC3O do V1 com
3154
+ -- \`order_id\` nulo, e a linha do servi\xE7o mora em \`appointment_items\`, n\xE3o em
3155
+ -- \`items\`. O V1 nunca teve esse beco: "Gerar Recebimento" l\xE1 CRIA a fatura a
3156
+ -- partir dos servi\xE7os do agendamento.
3157
+ --
3158
+ -- A tradu\xE7\xE3o para o V2 respeita o caminho \xFAnico do dinheiro: a cobran\xE7a n\xE3o
3159
+ -- escreve fatura \u2014 ela LEVANTA O PEDIDO pela porta do n\xFAcleo (\`order_create\`,
3160
+ -- a mesma que \`agenda_booking_create\` usa) com as linhas COM PRE\xC7O dos
3161
+ -- \`appointment_items\`, amarra o pedido ao agendamento e segue o fechamento
3162
+ -- normal (\`orders_raise_receivable_on_close\`).
3163
+ --
3164
+ -- Linha sem pre\xE7o n\xE3o vira pedido: revis\xE3o e avalia\xE7\xE3o gratuitas continuam
3165
+ -- sem cobran\xE7a, e a recusa \`nada_contratado\` continua falando por elas.
3166
+ -- ============================================================================
3167
+
3168
+ CREATE OR REPLACE FUNCTION public.clinical_checkout_charge(p_booking uuid)
3169
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
3170
+ AS $fn$
3171
+ DECLARE
3172
+ a record; v_linhas int; v_total numeric; v_mov int; v_lines jsonb; v_order uuid;
3173
+ BEGIN
3174
+ SELECT ap.id, ap.tenant_id, ap.kind, ap.status, ap.order_id,
3175
+ ap.party_id, ap.assignee_id, ap.unit_id
3176
+ INTO a
3177
+ FROM public.appointments ap WHERE ap.id = p_booking;
3178
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
3179
+ IF auth.uid() IS NULL THEN RETURN jsonb_build_object('ok', false, 'reason', 'sem_sessao'); END IF;
3180
+ -- O agendamento de OUTRA conta n\xE3o existe para esta sess\xE3o.
3181
+ IF a.tenant_id IS DISTINCT FROM app.current_tenant_id() THEN
3182
+ RETURN jsonb_build_object('ok', false, 'reason', 'not_found');
3183
+ END IF;
3184
+
3185
+ IF a.order_id IS NULL THEN
3186
+ -- As linhas com pre\xE7o do agendamento migrado. Sem nenhuma, n\xE3o h\xE1 o que
3187
+ -- cobrar \u2014 e isso \xE9 um estado leg\xEDtimo (revis\xE3o, avalia\xE7\xE3o gratuita).
3188
+ SELECT jsonb_agg(jsonb_build_object(
3189
+ 'service_id', ai.service_id,
3190
+ 'name', ai.name,
3191
+ 'quantity', 1,
3192
+ 'unit_price', ai.price,
3193
+ 'sort_order', ai.sort_order,
3194
+ 'duration_minutes', ai.duration_minutes,
3195
+ 'assignee_id', coalesce(ai.assignee_id, a.assignee_id)
3196
+ ) ORDER BY ai.sort_order)
3197
+ INTO v_lines
3198
+ FROM public.appointment_items ai
3199
+ WHERE ai.booking_id = a.id AND coalesce(ai.price, 0) > 0;
3200
+
3201
+ IF v_lines IS NULL THEN
3202
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_contratado');
3203
+ END IF;
3204
+
3205
+ v_order := public.order_create(
3206
+ p_kind => coalesce(a.kind, 'appointment'),
3207
+ p_lines => v_lines,
3208
+ p_party_id => a.party_id,
3209
+ p_unit_id => a.unit_id,
3210
+ p_status => 'scheduled',
3211
+ p_assignee_id => a.assignee_id,
3212
+ p_metadata => jsonb_build_object('source', 'clinical_checkout_charge', 'booking_id', a.id));
3213
+
3214
+ UPDATE public.appointments SET order_id = v_order, updated_at = now() WHERE id = a.id;
3215
+ a.order_id := v_order;
3216
+ END IF;
3217
+
3218
+ SELECT count(*), coalesce(sum(i.total), 0) INTO v_linhas, v_total
3219
+ FROM public.items i WHERE i.order_id = a.order_id;
3220
+ IF v_linhas = 0 THEN
3221
+ RETURN jsonb_build_object('ok', false, 'reason', 'nada_contratado');
3222
+ END IF;
3223
+
3224
+ -- Idempotente: o pedido j\xE1 fechado devolve o que existe, sem levantar um
3225
+ -- segundo receb\xEDvel para a mesma visita.
3226
+ SELECT count(*) INTO v_mov FROM public.plg_financial_movements m WHERE m.invoice_id = a.order_id;
3227
+ IF v_mov > 0 THEN
3228
+ RETURN jsonb_build_object('ok', true, 'ja_cobrado', true, 'total', v_total);
3229
+ END IF;
3230
+
3231
+ UPDATE public.orders SET status = 'completed', updated_at = now()
3232
+ WHERE id = a.order_id AND status <> 'completed';
3233
+
3234
+ SELECT count(*) INTO v_mov FROM public.plg_financial_movements m WHERE m.invoice_id = a.order_id;
3235
+ RETURN jsonb_build_object('ok', true, 'total', v_total, 'movimentos', v_mov);
3236
+ END $fn$;
3237
+
3238
+ GRANT EXECUTE ON FUNCTION public.clinical_checkout_charge(uuid) TO authenticated;
3239
+ `;
3240
+ var MIGRATION_021_AGENDAMENTO_NASCIDO_NO_V2_TAMBEM_TEM_REGUA = `-- ============================================================================
3241
+ -- O AGENDAMENTO NASCIDO NO V2 TAMB\xC9M TEM R\xC9GUA
3242
+ --
3243
+ -- O Iniciar atendimento percorre \`appointment_items\` \u2014 a linha do agendamento
3244
+ -- do V1, onde as 314 mil linhas migradas moram. S\xF3 que o Novo Agendamento do
3245
+ -- V2 grava as linhas pelo caminho do PEDIDO (\`agenda_booking_create\` \u2192
3246
+ -- \`orders\` + \`items\`), e nunca escreve \`appointment_items\`. Resultado, visto
3247
+ -- na tela em 23/09: o bot\xE3o vira "Em atendimento", e a r\xE9gua nasce VAZIA \u2014
3248
+ -- zero sess\xF5es, zero exig\xEAncias, bolas nenhuma. Todo agendamento criado pela
3249
+ -- pr\xF3pria UI do V2 ca\xEDa nisso; s\xF3 os migrados do V1 funcionavam.
3250
+ --
3251
+ -- O conserto MATERIALIZA a linha do agendamento na hora de come\xE7ar: se o
3252
+ -- agendamento tem pedido e ainda n\xE3o tem \`appointment_items\`, elas nascem das
3253
+ -- linhas do pedido \u2014 e o resto do fluxo (sess\xF5es, FK de \`booking_item_id\`,
3254
+ -- r\xE9gua, produtos por servi\xE7o) continua lendo o modelo que sempre leu.
3255
+ -- Idempotente: s\xF3 insere quando n\xE3o h\xE1 linha nenhuma para o agendamento.
3256
+ -- ============================================================================
3257
+
3258
+ CREATE OR REPLACE FUNCTION public.clinical_attendance_start(p_booking uuid)
3259
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public', 'app'
3260
+ AS $fn$
3261
+ DECLARE
3262
+ a record; v_person uuid; v_created int := 0; v_adotadas int := 0;
3263
+ v_item record; v_refresh jsonb; v_antiga uuid;
3264
+ BEGIN
3265
+ SELECT ap.id, ap.tenant_id, ap.unit_id, ap.party_id, ap.assignee_id, ap.status,
3266
+ ap.starts_at, ap.order_id
3267
+ INTO a FROM public.appointments ap WHERE ap.id = p_booking;
3268
+ IF NOT FOUND THEN
3269
+ RETURN jsonb_build_object('ok', false, 'reason', 'not_found');
3270
+ END IF;
3271
+
3272
+ v_person := public.current_person_id();
3273
+ IF v_person IS NULL THEN
3274
+ RETURN jsonb_build_object('ok', false, 'reason', 'sem_ficha_de_profissional');
3275
+ END IF;
3276
+ IF a.assignee_id IS DISTINCT FROM v_person THEN
3277
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_seu_atendimento');
3278
+ END IF;
3279
+ IF a.starts_at::date <> (now() AT TIME ZONE 'America/Sao_Paulo')::date THEN
3280
+ RETURN jsonb_build_object('ok', false, 'reason', 'nao_e_de_hoje');
3281
+ END IF;
3282
+ IF a.status IN ('completed', 'cancelled', 'no_show') THEN
3283
+ RETURN jsonb_build_object('ok', false, 'reason', 'ja_encerrado', 'status', a.status);
3284
+ END IF;
3285
+
3286
+ -- Agendamento nascido no V2: as linhas vivem no pedido. Materializa a linha
3287
+ -- do agendamento \u2014 uma vez s\xF3 \u2014 para o resto do fluxo ter onde se pendurar.
3288
+ IF a.order_id IS NOT NULL
3289
+ AND NOT EXISTS (SELECT 1 FROM public.appointment_items ai WHERE ai.booking_id = a.id) THEN
3290
+ INSERT INTO public.appointment_items (
3291
+ tenant_id, unit_id, booking_id, service_id, name, price,
3292
+ duration_minutes, assignee_id, sort_order)
3293
+ -- \`coalesce(product_id, service_id)\`: o \`order_create\` grava o servi\xE7o em
3294
+ -- \`service_id\`; a edi\xE7\xE3o pela agenda grava em \`product_id\` (spine 190).
3295
+ -- As duas gera\xE7\xF5es de linha existem no banco.
3296
+ SELECT a.tenant_id, a.unit_id, a.id, coalesce(it.product_id, it.service_id),
3297
+ it.name, it.unit_price,
3298
+ it.duration_minutes, coalesce(it.assignee_id, a.assignee_id),
3299
+ coalesce(it.sort_order, 0)
3300
+ FROM public.items it
3301
+ WHERE it.order_id = a.order_id;
3302
+ END IF;
3303
+
3304
+ FOR v_item IN
3305
+ SELECT ai.id, ai.service_id, ai.assignee_id
3306
+ FROM public.appointment_items ai
3307
+ WHERE ai.booking_id = p_booking
3308
+ LOOP
3309
+ -- J\xE1 existe sess\xE3o para este item? (dois toques, duas pessoas ao mesmo tempo)
3310
+ CONTINUE WHEN EXISTS (
3311
+ SELECT 1 FROM public.plg_clinical_sessions cs
3312
+ WHERE cs.booking_id = p_booking AND cs.booking_item_id = v_item.id);
3313
+
3314
+ -- O QUE FOI COMPRADO ANTES E AINDA N\xC3O SAIU: adota em vez de criar.
3315
+ SELECT cs.id INTO v_antiga
3316
+ FROM public.plg_clinical_sessions cs
3317
+ WHERE cs.tenant_id = a.tenant_id
3318
+ AND cs.party_id = a.party_id
3319
+ AND cs.service_id = v_item.service_id
3320
+ AND cs.booking_id IS NULL
3321
+ AND cs.status IN ('contracted', 'quoted', 'pending')
3322
+ ORDER BY cs.created_at
3323
+ LIMIT 1;
3324
+
3325
+ IF v_antiga IS NOT NULL THEN
3326
+ UPDATE public.plg_clinical_sessions
3327
+ SET booking_id = p_booking,
3328
+ booking_item_id = v_item.id,
3329
+ unit_id = coalesce(a.unit_id, unit_id),
3330
+ assignee_id = coalesce(v_item.assignee_id, a.assignee_id),
3331
+ performed_by_id = v_person,
3332
+ status = 'in_service',
3333
+ scheduled_for = a.starts_at,
3334
+ started_at = coalesce(started_at, now()),
3335
+ updated_at = now()
3336
+ WHERE id = v_antiga;
3337
+ v_adotadas := v_adotadas + 1;
3338
+ ELSE
3339
+ INSERT INTO public.plg_clinical_sessions (
3340
+ tenant_id, unit_id, status, booking_id, booking_item_id, party_id,
3341
+ assignee_id, performed_by_id, service_id, scheduled_for, started_at)
3342
+ VALUES (a.tenant_id, a.unit_id, 'in_service', a.id, v_item.id, a.party_id,
3343
+ coalesce(v_item.assignee_id, a.assignee_id), v_person, v_item.service_id,
3344
+ a.starts_at, now());
3345
+ v_created := v_created + 1;
3346
+ END IF;
3347
+ END LOOP;
3348
+
3349
+ UPDATE public.appointments
3350
+ SET status = 'in_progress', updated_at = now()
3351
+ WHERE id = p_booking AND status <> 'in_progress';
3352
+
3353
+ v_refresh := public.clinical_requirements_refresh(p_booking);
3354
+
3355
+ RETURN jsonb_build_object(
3356
+ 'ok', true,
3357
+ 'sessoes_criadas', v_created,
3358
+ 'sessoes_adotadas', v_adotadas,
3359
+ 'sessoes', (SELECT count(*) FROM public.plg_clinical_sessions WHERE booking_id = p_booking),
3360
+ 'regua', v_refresh);
3361
+ END $fn$;
3362
+
3363
+ GRANT EXECUTE ON FUNCTION public.clinical_attendance_start(uuid) TO authenticated;
3364
+ `;
3365
+ var MIGRATION_022_A_VISTA_DO_QUE_EXECUTAR_RESPEITA_A_CERCA = `-- ============================================================================
3366
+ -- THE "TO EXECUTE" VIEW READS WITH THE CALLER'S PRIVILEGES
3367
+ --
3368
+ -- \`v_clinical_to_execute\` was created in 014 without \`security_invoker\`, so it
3369
+ -- ran with the privileges of the role that OWNS it, and a view owned by a
3370
+ -- privileged role does not apply the row policies of the tables beneath it.
3371
+ -- \`plg_clinical_sessions\`, \`items\` and \`plg_financial_movements\` are all
3372
+ -- tenant-scoped with RLS; read through this view, they were not.
3373
+ --
3374
+ -- Not theoretical: the view answers, per party, how much a patient has already
3375
+ -- PAID for procedures not yet performed. Without the fence that is every
3376
+ -- clinic's prepaid balance, to anyone holding any session.
3377
+ --
3378
+ -- \u2500\u2500 why nobody saw it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3379
+ --
3380
+ -- N16 of the posture suite grades exactly this ("every public view over a
3381
+ -- tenant_id table is security_invoker"), and it stayed green throughout:
3382
+ -- plugin-clinical was never listed in \`packages/db/chain.json\`, so its objects
3383
+ -- were never built on the bench and never graded. The plugin reached a live
3384
+ -- pool on 2026-09-22 and no board at all. Putting it in the chain is what made
3385
+ -- the suite fail, on the first run, in the expected place.
3386
+ --
3387
+ -- \u2500\u2500 why a new file, and not an edit to 014 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3388
+ --
3389
+ -- 014 is in \`public._migrations\` on a live pool with checksum 6f04df24cdb2,
3390
+ -- and the ledger treats a changed checksum on an applied file as a HARD STOP
3391
+ -- (cli/src/lib/ledger.ts). Editing it in place would not fix the view \u2014 it
3392
+ -- would stop \`fayz db apply\` on every cluster that already ran it.
3393
+ --
3394
+ -- The body below is 014's, copied unchanged. \`CREATE OR REPLACE VIEW\` refuses
3395
+ -- a different column list, and the only thing that may differ here is who the
3396
+ -- row filter runs as.
3397
+ -- ============================================================================
3398
+
3399
+ CREATE OR REPLACE VIEW public.v_clinical_to_execute WITH (security_invoker = true) AS
3400
+ WITH sessao AS (
3401
+ SELECT
3402
+ cs.tenant_id,
3403
+ cs.party_id,
3404
+ cs.order_item_id,
3405
+ cs.status,
3406
+ i.order_id,
3407
+ coalesce(i.total, 0) AS item_total
3408
+ FROM public.plg_clinical_sessions cs
3409
+ JOIN public.items i ON i.id = cs.order_item_id
3410
+ WHERE cs.party_id IS NOT NULL
3411
+ AND cs.status IN ('contracted', 'quoted', 'in_service', 'scheduled', 'pending')
3412
+ ),
3413
+ -- Quanto do PEDIDO j\xE1 foi recebido, em propor\xE7\xE3o: o pagamento \xE9 por parcela e
3414
+ -- n\xE3o por item, ent\xE3o a fatia paga de cada item \xE9 a fatia paga do pedido.
3415
+ pago AS (
3416
+ SELECT m.invoice_id AS order_id,
3417
+ sum(coalesce(m.paid_amount, 0)) AS pago,
3418
+ nullif(sum(coalesce(m.amount, 0)), 0) AS total
3419
+ FROM public.plg_financial_movements m
3420
+ WHERE m.direction = 'credit'
3421
+ GROUP BY m.invoice_id
3422
+ )
3423
+ SELECT
3424
+ s.tenant_id,
3425
+ s.party_id,
3426
+ sum(s.item_total * coalesce(p.pago / p.total, 0))::numeric(14,2) AS paid_to_execute,
3427
+ sum(s.item_total * (1 - coalesce(p.pago / p.total, 0)))::numeric(14,2) AS open_to_execute,
3428
+ count(*)::int AS sessions_to_execute
3429
+ FROM sessao s
3430
+ LEFT JOIN pago p ON p.order_id = s.order_id
3431
+ GROUP BY s.tenant_id, s.party_id;
3432
+ `;
3433
+ var MIGRATION_023_A_REVISAO_SE_ESCOLHE_AO_AGENDAR = `-- ============================================================================
3434
+ -- 023 \u2014 The revision is chosen when it is booked, not only during the visit.
3435
+ -- ============================================================================
3436
+ -- V1 asks "De qual servi\xE7o \xE9 esta revis\xE3o?" the moment the reception picks the
3437
+ -- Revis\xE3o service in Novo Agendamento (FAY-1613). 015 answered it only from an
3438
+ -- existing booking. This lists the same candidates for a PERSON, with the ordinal
3439
+ -- the next revision of each will take (V1 shows "1\xAA Revis\xE3o").
3440
+ --
3441
+ -- Also closes a gap in 015: \`clinical_revision_candidates(p_booking)\` is a
3442
+ -- definer function that read any booking id it was given, so a member of one
3443
+ -- tenant could list another tenant's patient history by guessing an id. Both
3444
+ -- now answer only inside the caller's tenant.
3445
+ --
3446
+ -- Idempotent.
3447
+
3448
+ CREATE OR REPLACE FUNCTION public.clinical_revision_candidates_for_person(p_person uuid)
3449
+ RETURNS TABLE(session_id uuid, service_id uuid, service_name text, executed_on timestamp with time zone,
3450
+ deadline_days integer, days_since integer, within_deadline boolean, next_revision_number integer)
3451
+ LANGUAGE sql
3452
+ STABLE SECURITY DEFINER
3453
+ SET search_path TO 'public', 'app'
3454
+ AS $function$
3455
+ SELECT
3456
+ cs.id,
3457
+ cs.service_id,
3458
+ p.name,
3459
+ coalesce(cs.ended_at, cs.scheduled_for) AS executed_on,
3460
+ rev.deadline_days,
3461
+ (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date)::int AS days_since,
3462
+ CASE WHEN rev.deadline_days IS NULL THEN NULL
3463
+ ELSE (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date) <= rev.deadline_days
3464
+ END AS within_deadline,
3465
+ coalesce((SELECT max(r.revision_number) FROM public.plg_clinical_sessions r
3466
+ WHERE r.tenant_id = cs.tenant_id AND r.parent_session_id = cs.id), 0) + 1
3467
+ FROM public.plg_clinical_sessions cs
3468
+ LEFT JOIN public.products p ON p.id = cs.service_id
3469
+ LEFT JOIN LATERAL (
3470
+ SELECT r.deadline_days FROM public.plg_clinical_service_revisions r
3471
+ WHERE r.tenant_id = cs.tenant_id AND r.service_id = cs.service_id
3472
+ ORDER BY r.revision_number LIMIT 1
3473
+ ) rev ON true
3474
+ WHERE cs.tenant_id = app.current_tenant_id()
3475
+ AND cs.party_id = p_person
3476
+ AND cs.status = 'executed'
3477
+ AND cs.parent_session_id IS NULL
3478
+ ORDER BY coalesce(cs.ended_at, cs.scheduled_for) DESC NULLS LAST
3479
+ LIMIT 50;
3480
+ $function$;
3481
+
3482
+ REVOKE ALL ON FUNCTION public.clinical_revision_candidates_for_person(uuid) FROM PUBLIC, anon;
3483
+ GRANT EXECUTE ON FUNCTION public.clinical_revision_candidates_for_person(uuid) TO authenticated;
3484
+
3485
+ CREATE OR REPLACE FUNCTION public.clinical_revision_candidates(p_booking uuid)
3486
+ RETURNS TABLE(session_id uuid, service_id uuid, service_name text, executed_on timestamp with time zone,
3487
+ deadline_days integer, days_since integer, within_deadline boolean)
3488
+ LANGUAGE sql
3489
+ STABLE SECURITY DEFINER
3490
+ SET search_path TO 'public', 'app'
3491
+ AS $function$
3492
+ WITH alvo AS (
3493
+ SELECT ap.tenant_id, ap.party_id FROM public.appointments ap
3494
+ WHERE ap.id = p_booking AND ap.tenant_id = app.current_tenant_id()
3495
+ )
3496
+ SELECT
3497
+ cs.id,
3498
+ cs.service_id,
3499
+ p.name,
3500
+ coalesce(cs.ended_at, cs.scheduled_for) AS executed_on,
3501
+ rev.deadline_days,
3502
+ (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date)::int AS days_since,
3503
+ CASE WHEN rev.deadline_days IS NULL THEN NULL
3504
+ ELSE (current_date - coalesce(cs.ended_at, cs.scheduled_for)::date) <= rev.deadline_days
3505
+ END AS within_deadline
3506
+ FROM public.plg_clinical_sessions cs
3507
+ JOIN alvo a ON a.tenant_id = cs.tenant_id AND a.party_id = cs.party_id
3508
+ LEFT JOIN public.products p ON p.id = cs.service_id
3509
+ LEFT JOIN LATERAL (
3510
+ SELECT r.deadline_days FROM public.plg_clinical_service_revisions r
3511
+ WHERE r.tenant_id = cs.tenant_id AND r.service_id = cs.service_id
3512
+ ORDER BY r.revision_number LIMIT 1
3513
+ ) rev ON true
3514
+ WHERE cs.status = 'executed'
3515
+ AND cs.booking_id IS DISTINCT FROM p_booking
3516
+ ORDER BY coalesce(cs.ended_at, cs.scheduled_for) DESC NULLS LAST
3517
+ LIMIT 50;
3518
+ $function$;
3519
+ `;
3520
+ var MIGRATION_024_A_AVALIACAO_DESTA_VISITA_VALE_PARA_O_QUE_ELA_CONTRATOU = `-- The evaluation done in this attendance covers what was contracted in it.
3521
+ --
3522
+ -- FAY-1613 (Espa\xE7o Facial, etapa 3): the professional fills the "Avalia\xE7\xE3o" form, indicates, the
3523
+ -- client contracts \u2014 and every contracted session came out with its own "Avalia\xE7\xE3o" step pending,
3524
+ -- because nothing in V2 points a contracted session at the evaluation that authorised it
3525
+ -- (\`source_evaluation_id\` is only set by the V1 import). V1 shows "Avalia\xE7\xE3o conclu\xEDda \u2014 o
3526
+ -- atendimento segue para a execu\xE7\xE3o" at that point.
3527
+ --
3528
+ -- Same body as live, plus step 5c. Idempotent (CREATE OR REPLACE).
3529
+
3530
+ CREATE OR REPLACE FUNCTION public.clinical_requirements_refresh(p_booking uuid, p_final boolean DEFAULT false)
3531
+ RETURNS jsonb
3532
+ LANGUAGE plpgsql
3533
+ SECURITY DEFINER
3534
+ SET search_path TO 'public'
3535
+ AS $function$
3536
+ DECLARE
3537
+ a record; v_mode text; v_now timestamptz := now(); v_eval jsonb;
3538
+ n_mat int := 0; n_reopen int := 0;
3539
+ -- \`v_sessao\`, n\xE3o \`r\`: plpgsql resolve o nome da VARI\xC1VEL antes do apelido da
3540
+ -- tabela, ent\xE3o um \`record r\` declarado aqui sequestra todo \`r.coluna\` dos
3541
+ -- UPDATEs abaixo \u2014 e o erro que sai \xE9 "record r has no field booking_id".
3542
+ v_sessao record;
3543
+ BEGIN
3544
+ SELECT ap.id, ap.tenant_id, ap.party_id, ap.status INTO a
3545
+ FROM public.appointments ap WHERE ap.id = p_booking;
3546
+ IF NOT FOUND THEN RETURN jsonb_build_object('ok', false, 'reason', 'not_found'); END IF;
3547
+
3548
+ v_mode := public.clinical_requirements_mode(a.tenant_id);
3549
+ IF v_mode = 'off' THEN RETURN jsonb_build_object('ok', true, 'mode', v_mode, 'skipped', true); END IF;
3550
+
3551
+ p_final := coalesce(p_final, false) AND a.status = 'completed';
3552
+
3553
+ BEGIN
3554
+ PERFORM pg_advisory_xact_lock(hashtextextended('clinical_req:' || p_booking::text, 0));
3555
+ IF a.status = 'completed' AND NOT p_final THEN
3556
+ RETURN jsonb_build_object('ok', true, 'mode', v_mode, 'frozen', true);
3557
+ END IF;
3558
+
3559
+ -- 1) Materializa o que falta (sess\xE3o nova, ficha t\xE9cnica criada depois).
3560
+ FOR v_sessao IN SELECT cs.id FROM public.plg_clinical_sessions cs WHERE cs.booking_id = p_booking
3561
+ LOOP
3562
+ n_mat := n_mat + public.clinical_requirements_materialize(v_sessao.id);
3563
+ END LOOP;
3564
+
3565
+ v_eval := coalesce((SELECT t.settings -> 'evaluation_flow' FROM public.tenants t WHERE t.id = a.tenant_id), '{}'::jsonb);
3566
+ IF jsonb_typeof(v_eval) <> 'object' THEN v_eval := '{}'::jsonb; END IF;
3567
+
3568
+ -- 1b) Regra que saiu da configura\xE7\xE3o para de bloquear (o upsert reabre se voltar).
3569
+ UPDATE public.plg_clinical_requirements r
3570
+ SET status = 'not_applicable', candidate_type = NULL, candidate_id = NULL, updated_at = v_now
3571
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.frozen_at IS NULL
3572
+ AND r.metadata -> 'migration' IS NULL
3573
+ AND ((r.kind = 'revision_link' AND NOT coalesce((v_eval ->> 'revision_link_required')::boolean, false))
3574
+ OR (r.kind = 'evaluation'
3575
+ AND r.rule_sources = '["evaluation_flow.require_evaluation_for_kind_service"]'::jsonb
3576
+ AND NOT coalesce((v_eval ->> 'require_evaluation_for_kind_service')::boolean, false)));
3577
+
3578
+ -- 2) Reabre o que perdeu a fonte. Linha congelada n\xE3o se mexe.
3579
+ UPDATE public.plg_clinical_requirements r
3580
+ SET status = 'pending', subject_type = NULL, subject_id = NULL, resolved_via = NULL,
3581
+ resolved_at = NULL, valid_until = NULL, updated_at = v_now
3582
+ WHERE r.booking_id = p_booking AND r.status = 'resolved' AND r.frozen_at IS NULL
3583
+ AND ((r.subject_type = 'documents' AND NOT EXISTS (SELECT 1 FROM public.documents d WHERE d.id = r.subject_id))
3584
+ OR (r.subject_type = 'clinical_sessions' AND NOT EXISTS (SELECT 1 FROM public.plg_clinical_sessions x WHERE x.id = r.subject_id))
3585
+ OR (r.subject_type = 'items' AND NOT EXISTS (SELECT 1 FROM public.items oi WHERE oi.id = r.subject_id)));
3586
+ GET DIAGNOSTICS n_reopen = ROW_COUNT;
3587
+
3588
+ -- 3) Formul\xE1rio universal: a \xFAltima resposta do cliente, em qualquer
3589
+ -- atendimento, ainda dentro da validade do modelo.
3590
+ UPDATE public.plg_clinical_requirements r
3591
+ SET status = 'resolved', subject_type = 'documents', subject_id = x.id,
3592
+ resolved_via = CASE WHEN x.subject_id = p_booking THEN 'attendance' ELSE 'auto_client' END,
3593
+ resolved_at = v_now,
3594
+ valid_until = CASE WHEN x.validity_days IS NULL THEN NULL
3595
+ ELSE x.eff + make_interval(days => x.validity_days) END,
3596
+ updated_at = v_now
3597
+ FROM (SELECT DISTINCT ON (fd.template_id) d.id, fd.template_id, d.subject_id,
3598
+ coalesce(d.updated_at, d.created_at) AS eff, ft.validity_days
3599
+ FROM public.plg_forms_documents fd
3600
+ JOIN public.documents d ON d.id = fd.document_id
3601
+ JOIN public.plg_forms_templates ft ON ft.id = fd.template_id
3602
+ WHERE d.person_id = a.party_id AND d.tenant_id = a.tenant_id
3603
+ ORDER BY fd.template_id, coalesce(d.updated_at, d.created_at) DESC) x
3604
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'form'
3605
+ AND r.scope = 'attendance' AND r.ref_id = x.template_id
3606
+ AND r.rule_sources @> '["forms_templates.required_on_attendance"]'::jsonb
3607
+ AND (x.validity_days IS NULL OR x.eff + make_interval(days => x.validity_days) > v_now);
3608
+
3609
+ -- 4) Demais formul\xE1rios: qualquer resposta DESTE atendimento com o modelo.
3610
+ UPDATE public.plg_clinical_requirements r
3611
+ SET status = 'resolved', subject_type = 'documents', subject_id = x.id,
3612
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
3613
+ FROM (SELECT DISTINCT ON (fd.template_id, coalesce(fd.product_id, '00000000-0000-0000-0000-000000000000'::uuid))
3614
+ d.id, fd.template_id, fd.product_id
3615
+ FROM public.plg_forms_documents fd
3616
+ JOIN public.documents d ON d.id = fd.document_id
3617
+ WHERE d.subject_type = 'appointment' AND d.subject_id = p_booking
3618
+ ORDER BY fd.template_id, coalesce(fd.product_id, '00000000-0000-0000-0000-000000000000'::uuid),
3619
+ coalesce(d.updated_at, d.created_at) DESC) x
3620
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'form'
3621
+ AND r.ref_id = x.template_id
3622
+ AND NOT (r.rule_sources @> '["forms_templates.required_on_attendance"]'::jsonb)
3623
+ AND (x.product_id IS NULL OR r.scope = 'attendance' OR x.product_id = r.service_id);
3624
+
3625
+ -- 5) Avalia\xE7\xE3o vinculada: a sess\xE3o aponta a avalia\xE7\xE3o que a autorizou.
3626
+ UPDATE public.plg_clinical_requirements r
3627
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = x.eval_id,
3628
+ resolved_via = CASE WHEN x.same_booking THEN 'attendance' ELSE 'auto_client' END,
3629
+ resolved_at = v_now, updated_at = v_now
3630
+ FROM (SELECT r2.id AS req_id, e.id AS eval_id, (e.booking_id = p_booking) AS same_booking
3631
+ FROM public.plg_clinical_requirements r2
3632
+ JOIN public.plg_clinical_sessions cs ON cs.id = r2.session_id
3633
+ JOIN public.plg_clinical_sessions e ON e.id = cs.source_evaluation_id
3634
+ WHERE r2.booking_id = p_booking AND r2.kind = 'evaluation' AND r2.frozen_at IS NULL
3635
+ AND (r2.status = 'pending' OR (r2.status = 'resolved' AND r2.resolved_via = 'legacy_grace'))) x
3636
+ WHERE r.id = x.req_id;
3637
+
3638
+ -- 5b) Gra\xE7a legada: o cliente j\xE1 tem atendimento conclu\xEDdo com servi\xE7o de
3639
+ -- avalia\xE7\xE3o. \xC9 a mesma concess\xE3o do V1, e vai marcada como tal.
3640
+ UPDATE public.plg_clinical_requirements r
3641
+ SET status = 'resolved', subject_type = 'appointments', subject_id = x.booking_id,
3642
+ resolved_via = 'legacy_grace', resolved_at = v_now, updated_at = v_now
3643
+ FROM (SELECT ap.id AS booking_id
3644
+ FROM public.appointments ap
3645
+ JOIN public.appointment_items ai ON ai.booking_id = ap.id
3646
+ JOIN public.products p ON p.id = ai.service_id AND p.service_kind = 'evaluation'
3647
+ WHERE ap.party_id = a.party_id AND ap.tenant_id = a.tenant_id AND ap.status = 'completed'
3648
+ ORDER BY ap.starts_at DESC NULLS LAST LIMIT 1) x
3649
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'evaluation';
3650
+
3651
+ -- 5c) Avalia\xE7\xE3o feita NESTE atendimento. Contratar depois de avaliar cria sess\xF5es novas, e cada
3652
+ -- uma nascia com a pr\xF3pria bola "Avalia\xE7\xE3o" pendente \u2014 o profissional acabou de avaliar.
3653
+ -- Indica\xE7\xE3o registrada aqui (o formul\xE1rio de avalia\xE7\xE3o) ou sess\xE3o de avalia\xE7\xE3o deste
3654
+ -- atendimento resolvem as sess\xF5es contratadas na mesma visita.
3655
+ UPDATE public.plg_clinical_requirements r
3656
+ SET status = 'resolved', subject_type = 'appointments', subject_id = p_booking,
3657
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
3658
+ WHERE r.booking_id = p_booking AND r.status = 'pending' AND r.kind = 'evaluation'
3659
+ AND r.frozen_at IS NULL
3660
+ AND (EXISTS (SELECT 1 FROM public.plg_crm_deal_interest_appointments x WHERE x.appointment_id = p_booking)
3661
+ OR EXISTS (SELECT 1 FROM public.plg_clinical_sessions e
3662
+ JOIN public.products p ON p.id = e.service_id AND p.service_kind = 'evaluation'
3663
+ WHERE e.booking_id = p_booking));
3664
+
3665
+ -- 6) Revis\xE3o vinculada ao servi\xE7o original.
3666
+ UPDATE public.plg_clinical_requirements r
3667
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = cs.parent_session_id,
3668
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
3669
+ FROM public.plg_clinical_sessions cs
3670
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
3671
+ AND r.status = 'pending' AND r.kind = 'revision_link'
3672
+ AND cs.parent_session_id IS NOT NULL
3673
+ AND EXISTS (SELECT 1 FROM public.plg_clinical_sessions p WHERE p.id = cs.parent_session_id);
3674
+
3675
+ -- 7) Produtos: a sess\xE3o declara que foram conferidos.
3676
+ UPDATE public.plg_clinical_requirements r
3677
+ SET status = 'resolved', subject_type = 'clinical_sessions', subject_id = cs.id,
3678
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
3679
+ FROM public.plg_clinical_sessions cs
3680
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
3681
+ AND r.status = 'pending' AND r.kind = 'products'
3682
+ AND coalesce(cs.products_initialized, false);
3683
+
3684
+ -- 8) Item de pedido vinculado. A linha do pedido no V2 \xE9 \`public.items\` \u2014
3685
+ -- n\xE3o existe \`order_items\`; o pedido \xE9 o documento e o item \xE9 dele.
3686
+ UPDATE public.plg_clinical_requirements r
3687
+ SET status = 'resolved', subject_type = 'items', subject_id = oi.id,
3688
+ resolved_via = 'attendance', resolved_at = v_now, updated_at = v_now
3689
+ FROM public.plg_clinical_sessions cs
3690
+ JOIN public.items oi ON oi.id = cs.order_item_id
3691
+ WHERE cs.id = r.session_id AND r.booking_id = p_booking
3692
+ AND r.status = 'pending' AND r.kind = 'invoice_item';
3693
+
3694
+ -- 9) Congelamento: s\xF3 na \xFAltima passada, com o atendimento conclu\xEDdo.
3695
+ IF p_final THEN
3696
+ UPDATE public.plg_clinical_requirements
3697
+ SET frozen_at = coalesce(frozen_at, v_now), updated_at = v_now
3698
+ WHERE booking_id = p_booking;
3699
+ END IF;
3700
+
3701
+ RETURN jsonb_build_object(
3702
+ 'ok', true, 'mode', v_mode, 'materialized', n_mat, 'reopened', n_reopen,
3703
+ 'pending', (SELECT count(*) FROM public.plg_clinical_requirements WHERE booking_id = p_booking AND status = 'pending'),
3704
+ 'resolved', (SELECT count(*) FROM public.plg_clinical_requirements WHERE booking_id = p_booking AND status = 'resolved'),
3705
+ 'frozen', p_final);
3706
+ EXCEPTION WHEN others THEN
3707
+ RAISE WARNING 'clinical_requirements_refresh(%): %', p_booking, sqlerrm;
3708
+ RETURN jsonb_build_object('ok', false, 'error', sqlerrm);
3709
+ END;
3710
+ END $function$;
3711
+ `;
3712
+ var MIGRATION_025_AS_REGRAS_DO_ATENDIMENTO_SAO_CONFIGURACAO = `-- The attendance rules 004 already reads, registered as setting keys so the
3713
+ -- settings screen can write them through tenant_setting_set (typed, permission
3714
+ -- checked). Same paths the V1 import wrote: tenants.settings.execution_requirements.mode
3715
+ -- and tenants.settings.evaluation_flow.*. Nested writes keep the sibling keys.
3716
+ --
3717
+ -- Idempotent.
3718
+ SELECT app.seed_setting_keys($$[
3719
+ { "key": "execution_requirements.mode", "type": "string", "default": "off", "scope": "tenant", "reader": "plugin:clinical",
3720
+ "description": "Execution requirements: off, shadow (warn only) or on (block). V1: general_settings.execution_requirements.mode." },
3721
+ { "key": "evaluation_flow.require_evaluation_for_kind_service", "type": "boolean", "default": false, "scope": "tenant", "reader": "plugin:clinical",
3722
+ "description": "A paid service needs a linked evaluation. V1: general_settings.evaluation_flow." },
3723
+ { "key": "evaluation_flow.revision_link_required", "type": "boolean", "default": false, "scope": "tenant", "reader": "plugin:clinical",
3724
+ "description": "A revision must be linked to the original service; blocks start and finish. V1: general_settings.evaluation_flow." }
3725
+ ]$$::jsonb);
3726
+ `;
3727
+ var MIGRATIONS = [
3728
+ { id: "001_o_atendimento_clinico_tem_onde_morar", sql: MIGRATION_001_O_ATENDIMENTO_CLINICO_TEM_ONDE_MORAR },
3729
+ { id: "002_o_prontuario_do_v1_atravessa", sql: MIGRATION_002_O_PRONTUARIO_DO_V1_ATRAVESSA },
3730
+ { id: "003_a_permissao_se_pergunta_por_unidade_nao_por_linha", sql: MIGRATION_003_A_PERMISSAO_SE_PERGUNTA_POR_UNIDADE_NAO_POR_LINHA },
3731
+ { id: "004_a_regua_volta_a_ser_apurada", sql: MIGRATION_004_A_REGUA_VOLTA_A_SER_APURADA },
3732
+ { id: "005_comecar_o_atendimento_e_um_gesto_so", sql: MIGRATION_005_COMECAR_O_ATENDIMENTO_E_UM_GESTO_SO },
3733
+ { id: "006_contratar_nao_e_pagar", sql: MIGRATION_006_CONTRATAR_NAO_E_PAGAR },
3734
+ { id: "007_finalizar_acende_o_balcao", sql: MIGRATION_007_FINALIZAR_ACENDE_O_BALCAO },
3735
+ { id: "008_conferir_nao_e_receber", sql: MIGRATION_008_CONFERIR_NAO_E_RECEBER },
3736
+ { id: "009_o_botao_de_cobrar_cobra", sql: MIGRATION_009_O_BOTAO_DE_COBRAR_COBRA },
3737
+ { id: "010_a_indicacao_vai_para_o_funil", sql: MIGRATION_010_A_INDICACAO_VAI_PARA_O_FUNIL },
3738
+ { id: "011_interesse_e_indicacao_na_mesma_linha", sql: MIGRATION_011_INTERESSE_E_INDICACAO_NA_MESMA_LINHA },
3739
+ { id: "012_o_que_foi_usado_espera_aprovacao", sql: MIGRATION_012_O_QUE_FOI_USADO_ESPERA_APROVACAO },
3740
+ { id: "013_o_que_couber_hoje", sql: MIGRATION_013_O_QUE_COUBER_HOJE },
3741
+ { id: "014_o_pago_a_executar_deixa_de_ser_invisivel", sql: MIGRATION_014_O_PAGO_A_EXECUTAR_DEIXA_DE_SER_INVISIVEL },
3742
+ { id: "015_a_revisao_aponta_o_que_revisa", sql: MIGRATION_015_A_REVISAO_APONTA_O_QUE_REVISA },
3743
+ { id: "016_a_compra_antecipada_nao_tem_agendamento_atras", sql: MIGRATION_016_A_COMPRA_ANTECIPADA_NAO_TEM_AGENDAMENTO_ATRAS },
3744
+ { id: "017_o_que_ja_foi_pago_nao_se_cobra_de_novo", sql: MIGRATION_017_O_QUE_JA_FOI_PAGO_NAO_SE_COBRA_DE_NOVO },
3745
+ { id: "018_o_que_foi_contratado_vira_sessao", sql: MIGRATION_018_O_QUE_FOI_CONTRATADO_VIRA_SESSAO },
3746
+ { id: "019_adiar_e_uma_escolha", sql: MIGRATION_019_ADIAR_E_UMA_ESCOLHA },
3747
+ { id: "020_cobrar_um_agendamento_sem_pedido_levanta_o_pedido", sql: MIGRATION_020_COBRAR_UM_AGENDAMENTO_SEM_PEDIDO_LEVANTA_O_PEDIDO },
3748
+ { id: "021_agendamento_nascido_no_v2_tambem_tem_regua", sql: MIGRATION_021_AGENDAMENTO_NASCIDO_NO_V2_TAMBEM_TEM_REGUA },
3749
+ { id: "022_a_vista_do_que_executar_respeita_a_cerca", sql: MIGRATION_022_A_VISTA_DO_QUE_EXECUTAR_RESPEITA_A_CERCA },
3750
+ { id: "023_a_revisao_se_escolhe_ao_agendar", sql: MIGRATION_023_A_REVISAO_SE_ESCOLHE_AO_AGENDAR },
3751
+ { id: "024_a_avaliacao_desta_visita_vale_para_o_que_ela_contratou", sql: MIGRATION_024_A_AVALIACAO_DESTA_VISITA_VALE_PARA_O_QUE_ELA_CONTRATOU },
3752
+ { id: "025_as_regras_do_atendimento_sao_configuracao", sql: MIGRATION_025_AS_REGRAS_DO_ATENDIMENTO_SAO_CONFIGURACAO }
3753
+ ];
3754
+
3755
+ export { MIGRATIONS, MIGRATION_001_O_ATENDIMENTO_CLINICO_TEM_ONDE_MORAR, MIGRATION_002_O_PRONTUARIO_DO_V1_ATRAVESSA, MIGRATION_003_A_PERMISSAO_SE_PERGUNTA_POR_UNIDADE_NAO_POR_LINHA, MIGRATION_004_A_REGUA_VOLTA_A_SER_APURADA, MIGRATION_005_COMECAR_O_ATENDIMENTO_E_UM_GESTO_SO, MIGRATION_006_CONTRATAR_NAO_E_PAGAR, MIGRATION_007_FINALIZAR_ACENDE_O_BALCAO, MIGRATION_008_CONFERIR_NAO_E_RECEBER, MIGRATION_009_O_BOTAO_DE_COBRAR_COBRA, MIGRATION_010_A_INDICACAO_VAI_PARA_O_FUNIL, MIGRATION_011_INTERESSE_E_INDICACAO_NA_MESMA_LINHA, MIGRATION_012_O_QUE_FOI_USADO_ESPERA_APROVACAO, MIGRATION_013_O_QUE_COUBER_HOJE, MIGRATION_014_O_PAGO_A_EXECUTAR_DEIXA_DE_SER_INVISIVEL, MIGRATION_015_A_REVISAO_APONTA_O_QUE_REVISA, MIGRATION_016_A_COMPRA_ANTECIPADA_NAO_TEM_AGENDAMENTO_ATRAS, MIGRATION_017_O_QUE_JA_FOI_PAGO_NAO_SE_COBRA_DE_NOVO, MIGRATION_018_O_QUE_FOI_CONTRATADO_VIRA_SESSAO, MIGRATION_019_ADIAR_E_UMA_ESCOLHA, MIGRATION_020_COBRAR_UM_AGENDAMENTO_SEM_PEDIDO_LEVANTA_O_PEDIDO, MIGRATION_021_AGENDAMENTO_NASCIDO_NO_V2_TAMBEM_TEM_REGUA, MIGRATION_022_A_VISTA_DO_QUE_EXECUTAR_RESPEITA_A_CERCA, MIGRATION_023_A_REVISAO_SE_ESCOLHE_AO_AGENDAR, MIGRATION_024_A_AVALIACAO_DESTA_VISITA_VALE_PARA_O_QUE_ELA_CONTRATOU, MIGRATION_025_AS_REGRAS_DO_ATENDIMENTO_SAO_CONFIGURACAO };
3756
+ //# sourceMappingURL=main.js.map
3757
+ //# sourceMappingURL=main.js.map