@fayz-ai/plugin-workforce 0.3.14 → 0.3.16

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,3141 @@
1
+ // src/migrations/index.ts
2
+ var MIGRATION_000_BASELINE = `-- O RH ganha ponto e escala.
3
+ --
4
+ -- D9 \xB7 Gente & Jornada de trabalho \xE9 o \xFAltimo dom\xEDnio inteiro do V1 sem NENHUMA
5
+ -- contraparte no V2. O registro de features do app diz isso feature por feature:
6
+ -- \`sch.templates\` P0 missing, \`sch.calendar\` P0 missing ("Nenhum m\xF3dulo do SDK \xE9
7
+ -- dono disso hoje \u2014 precisa de uma decis\xE3o antes de c\xF3digo"), \`sch.clock\` P1
8
+ -- missing ("O que existe \xE9 o hor\xE1rio PREVISTO de trabalho. A batida realizada,
9
+ -- com ajuste auditado, n\xE3o existe"), \`sch.clock.adjust\` P1 missing. O host
10
+ -- previsto para os quatro \xE9 o mesmo: **plugin-workforce**. Este arquivo \xE9 a
11
+ -- camada de dados dele.
12
+ --
13
+ -- \u2500\u2500 O que foi medido na origem (Pertinho do C\xE9u, licen\xE7a 8, 07/09/2026) \u2500\u2500\u2500\u2500\u2500\u2500
14
+ --
15
+ -- Da documenta\xE7\xE3o do V1 (\`docs/v1-guide/restaurante/escalas/FLOW.md\` e
16
+ -- \`docs/v1-guide/agenda/ponto-eletronico/FLOW.md\`), tudo verificado na tela:
17
+ --
18
+ -- 325 registros de ponto no per\xEDodo \u2014 e TODOS os 325 marcados
19
+ -- "Fora da escala" (V1-043). A coluna existe justamente para cruzar
20
+ -- ponto \xD7 escala e nunca varia: o ponto est\xE1 ativo em \`PC Servi\xE7os\`
21
+ -- enquanto a opera\xE7\xE3o e as escalas s\xE3o de \`Pertinho do C\xE9u\`.
22
+ -- 20 batimentos EM ABERTO, incluindo 5 de 5 hoje e 8 de 16 ontem; um
23
+ -- deles com entrada \xE0s 23:29 e "13h07 (parcial)".
24
+ -- R$ 47.747 o valor do per\xEDodo \u2014 SUBESTIMADO, porque o V1 s\xF3 calcula valor no
25
+ -- fechamento do par, e 20 pares nunca fecharam.
26
+ -- 103 profissionais habilitados para arrastar numa vaga de template.
27
+ -- 7 templates de escala no tenant.
28
+ --
29
+ -- Tr\xEAs dos n\xFAmeros acima s\xE3o sintoma do MESMO defeito de modelo, e \xE9 ele que
30
+ -- este arquivo existe para n\xE3o repetir: no V1 a batida \xE9 um par entrada/sa\xEDda
31
+ -- numa \xFAnica linha, com o ajuste como colunas \`*_adjusted_at\` por cima. Um par
32
+ -- que nunca fecha n\xE3o tem dura\xE7\xE3o, n\xE3o tem valor e n\xE3o tem como ser conferido
33
+ -- contra nada.
34
+ --
35
+ -- \u2500\u2500 Duas decis\xF5es de arquitetura que o reposit\xF3rio j\xE1 tomou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
36
+ --
37
+ -- 1. *"Ponto \xE9 raz\xE3o append-only, como todo saldo \u2014 nunca uma coluna que algu\xE9m
38
+ -- edita."* (\`docs/migration/how-to-port.json\`, mv.sch.clock: *"Batida \xE9
39
+ -- append-only e ajuste \xE9 registro separado com autor e motivo \u2014 apagar
40
+ -- batida \xE9 o que a lei n\xE3o permite."*)
41
+ --
42
+ -- Por isso \`plg_workforce_time_entries\` \xE9 append-only de verdade \u2014 trigger
43
+ -- que recusa UPDATE e DELETE, e as policies de UPDATE/DELETE derrubadas
44
+ -- depois do scaffold \u2014 e o ajuste do V1 (\`checkin_adjusted_at\`,
45
+ -- \`checkout_adjusted_at\`, \`adjustment_reason\`, \`adjusted_by_*\`) vira LINHA em
46
+ -- \`plg_workforce_time_adjustments\`, com autor, motivo e carimbo. A pr\xF3pria
47
+ -- tela do V1 j\xE1 enuncia a regra que o V2 passa a garantir no banco: *"Os
48
+ -- hor\xE1rios reais dos batimentos s\xE3o preservados."*
49
+ --
50
+ -- 2. *"Escala de funcion\xE1rio N\xC3O mora na agenda \u2014 quem oferta hor\xE1rio \xE9 o RH, a
51
+ -- agenda s\xF3 consome."* (registro de features, dom\xEDnio D3.)
52
+ --
53
+ -- Por isso a escala \xE9 deste plugin, e a ponte com a agenda \xE9 UMA coluna:
54
+ -- \`plg_workforce_schedule_assignments.schedule_row_id\`, que aponta para a
55
+ -- linha de \`public.schedules\` que a publica\xE7\xE3o materializa.
56
+ --
57
+ -- \u2500\u2500 O que este arquivo N\xC3O cria, porque j\xE1 existe \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
58
+ --
59
+ -- O PREVISTO j\xE1 est\xE1 no V2. \`public.schedules\` (spine) tem exatamente a forma
60
+ -- de uma oferta de hor\xE1rio \u2014 \`kind\`, \`assignee_id\` \u2192 \`public.people\`,
61
+ -- \`day_of_week\`, \`specific_date\`, \`starts_at\`, \`ends_at\`, \`unit_id\` \u2014 e
62
+ -- \`public.schedule_kinds\` \xE9 o registro de esp\xE9cies dela (hoje: working_hours,
63
+ -- exception, block, unclassified). Criar uma tabela de escala prevista aqui
64
+ -- seria a segunda tabela de hor\xE1rio do cluster, que diverge da primeira na
65
+ -- pr\xF3xima mudan\xE7a. Em vez disso este arquivo **semeia a esp\xE9cie \`work_shift\`** e
66
+ -- usa \`public.schedules\`.
67
+ --
68
+ -- \`public.people\` tamb\xE9m j\xE1 resolve sozinha o pior defeito do modelo do V1: l\xE1 o
69
+ -- ponto aponta para \`person_type ENUM('professional','employee')\` + \`person_id\`,
70
+ -- uma FK polim\xF3rfica para duas tabelas de cadastro que a pr\xF3pria tela mistura
71
+ -- ("A aba mistura Profissional e Funcion\xE1rio no mesmo filtro e na mesma lista, e
72
+ -- a diferen\xE7a entre os dois cadastros n\xE3o \xE9 explicada em lugar nenhum"). No V2
73
+ -- os dois j\xE1 s\xE3o \`public.people\` com papel em \`public.person_roles\`, ent\xE3o o par
74
+ -- vira UMA FK \`person_id uuid \u2192 public.people(id)\`.
75
+ --
76
+ -- N\xC3O porta \`schedule_grids\` / \`schedule_grid_blocks\`: apesar do nome s\xE3o grade
77
+ -- de disponibilidade de AGENDAMENTO, n\xE3o escala de trabalho. Ficam registradas
78
+ -- em \`migration.excluded_tables\` com o motivo, que \xE9 mais barato que descobrir
79
+ -- isso de novo daqui a seis meses.
80
+ --
81
+ -- \u2500\u2500 As tr\xEAs lacunas do V1 que o V2 resolve aqui \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
82
+ --
83
+ -- (a) FECHAMENTO DE COMPET\xCANCIA. O V1 n\xE3o tem nenhum. Qualquer ajuste pode
84
+ -- mudar, em sil\xEAncio, um m\xEAs de horas j\xE1 entregue ao contador e j\xE1 pago \u2014
85
+ -- e a \xFAnica trava que existe \xE9 de tela: \`payable_invoice_id\` preenchido
86
+ -- esconde o bot\xE3o "Ajustar". Uma trava de tela \xE9 uma trava at\xE9 a primeira
87
+ -- chamada de API.
88
+ -- \u2192 \`plg_workforce_periods\`, no MESMO padr\xE3o que o financeiro j\xE1 provou em
89
+ -- \`plugin-financial/src/migrations/006\`: compet\xEAncia \xE9 o M\xCAS (data no dia
90
+ -- 1\xBA, por constraint), \xE9 do TENANT e n\xE3o da unidade (um m\xEAs meio fechado \xE9
91
+ -- a pior resposta poss\xEDvel para quem precisa dizer que acabou), a escrita
92
+ -- \xE9 s\xF3 por RPC (\`workforce_period_close\` / \`workforce_period_reopen\`, sem
93
+ -- policy de INSERT/UPDATE \u2014 uma policy de UPDATE seria a porta dos fundos
94
+ -- da trava), reabrir exige motivo, e a permiss\xE3o de fechar \xE9 pr\xF3pria
95
+ -- (\`workforce.close_period\`): quem bate ponto n\xE3o \xE9 quem fecha o m\xEAs.
96
+ -- A trava vira trigger sobre a batida E sobre o ajuste, e o ajuste ganha
97
+ -- a segunda trava do V1, agora no banco: batida j\xE1 vinculada a um t\xEDtulo
98
+ -- a pagar n\xE3o aceita ajuste.
99
+ --
100
+ -- (b) JORNADA PREVISTA \xD7 REALIZADA. No V1 elas nunca se encontram: a coluna
101
+ -- "Escala" da lista de ponto existe para isso e marcou 325 de 325 registros
102
+ -- como "Fora da escala".
103
+ -- \u2192 O PREVISTO mora em \`public.schedules\` com \`kind = 'work_shift'\` (n\xE3o em
104
+ -- tabela nova \u2014 ver acima); o REALIZADO mora em
105
+ -- \`plg_workforce_time_entries\`; e o cruzamento \xE9 a view
106
+ -- \`public.v_workforce_day\`, um FULL OUTER JOIN de prop\xF3sito, para que as
107
+ -- duas assimetrias apare\xE7am em vez de sumirem: previsto sem realizado \xE9
108
+ -- FALTA, realizado sem previsto \xE9 FORA DA ESCALA. A publica\xE7\xE3o da escala
109
+ -- materializa a linha de \`public.schedules\` e guarda o id em
110
+ -- \`plg_workforce_schedule_assignments.schedule_row_id\`, que \xE9 o que torna
111
+ -- o cruzamento uma chave e n\xE3o uma heur\xEDstica de nome e hor\xE1rio.
112
+ --
113
+ -- (c) INTERVALO / ALMO\xC7O. O V1 tem UM par entrada/sa\xEDda por ciclo. Quem sai para
114
+ -- almo\xE7ar e volta ou n\xE3o registra o intervalo, ou bate a sa\xEDda e a entrada e
115
+ -- o dia vira dois registros que ningu\xE9m soma. Num restaurante com turno
116
+ -- partido isso \xE9 a jornada inteira.
117
+ -- \u2192 A batida deixa de ser um PAR numa linha e passa a ser um EVENTO:
118
+ -- \`kind \u2208 (clock_in, break_start, break_end, clock_out)\`, N por dia,
119
+ -- agrupados por \`(person_id, work_date)\` e ordenados por \`seq\`. A mesma
120
+ -- decis\xE3o que satisfaz o append-only resolve o intervalo de gra\xE7a, e ainda
121
+ -- responde "quais pontos est\xE3o em aberto?" sem coluna derivada: em aberto
122
+ -- \xE9 o dia cujos eventos de abertura (clock_in, break_end) n\xE3o empatam com
123
+ -- os de fechamento (clock_out, break_start).
124
+ -- \`work_date\` \xE9 coluna, e n\xE3o \`punched_at::date\`, porque o V1 j\xE1 convive
125
+ -- com o turno que cruza a meia-noite \u2014 a pr\xF3pria caixa de ajuste avisa:
126
+ -- *"\xFAtil quando o funcion\xE1rio esqueceu de bater a sa\xEDda e o registro
127
+ -- cruzou para o dia seguinte"*. A jornada da noite de s\xE1bado \xE9 de s\xE1bado.
128
+ --
129
+ -- \u2500\u2500 Uma decis\xE3o a mais, tomada na fronteira da migra\xE7\xE3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
130
+ --
131
+ -- No molde do V1, titular vs stand-by \xE9 POSICIONAL: s\xE3o titulares os primeiros
132
+ -- \`required_count\` de \`schedule_template_assignments\` por \`priority\`, e o resto
133
+ -- \xE9 reserva. Ningu\xE9m grava isso; \xE9 uma regra que vive no c\xF3digo da tela. Na
134
+ -- inst\xE2ncia o V1 j\xE1 tem \`role\` como coluna de verdade. Aqui o molde tamb\xE9m tem:
135
+ -- \`plg_workforce_schedule_template_assignments.role\` \xE9 coluna, e o writer da
136
+ -- migra\xE7\xE3o CALCULA a posi\xE7\xE3o uma vez, contra o \`required_count\` da fun\xE7\xE3o,
137
+ -- em vez de deixar a regra escondida em cada leitor que vier depois.
138
+ --
139
+ -- Idempotente. Nada aqui apaga dado.
140
+
141
+ -- A RPC de fechamento consulta a view de cruzamento, que nasce tr\xEAs se\xE7\xF5es
142
+ -- depois dela \u2014 as duas moram onde o assunto est\xE1, n\xE3o onde o carregador
143
+ -- preferiria. Sess\xE3o, e \xE9 o que os baselines gerados do cluster j\xE1 fazem.
144
+ SET check_function_bodies = false;
145
+
146
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
147
+ -- 0. O plugin existe: as permiss\xF5es
148
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
149
+ --
150
+ -- O scaffold desenha as policies em cima de \`<plugin>.read/create/edit/delete\`
151
+ -- e \`<plugin>.read_all\`. Sem essas chaves no cat\xE1logo, a policy compila e nega
152
+ -- tudo para todo mundo \u2014 uma tabela protegida por n\xE3o existir para ningu\xE9m.
153
+ --
154
+ -- Duas chaves s\xE3o separadas de prop\xF3sito, e as duas s\xE3o segrega\xE7\xE3o de fun\xE7\xE3o:
155
+ -- \`workforce.adjust_time\` porque corrigir a batida de outra pessoa \xE9 outra
156
+ -- ordem de decis\xE3o que editar uma escala, e \`workforce.close_period\` porque
157
+ -- quem aponta n\xE3o \xE9 quem fecha o m\xEAs.
158
+ SELECT app.seed_permissions('[
159
+ {"key": "workforce.read", "plugin": "workforce", "description": "Ver escala, ponto e compet\xEAncia."},
160
+ {"key": "workforce.create", "plugin": "workforce", "description": "Criar escala, template e batida."},
161
+ {"key": "workforce.edit", "plugin": "workforce", "description": "Editar escala e template."},
162
+ {"key": "workforce.delete", "plugin": "workforce", "description": "Excluir escala e template."},
163
+ {"key": "workforce.manage", "plugin": "workforce", "description": "Administrar o m\xF3dulo de jornada."},
164
+ {"key": "workforce.read_all", "plugin": "workforce", "description": "Ver a jornada de todo mundo, e n\xE3o s\xF3 a pr\xF3pria."},
165
+ {"key": "workforce.adjust_time", "plugin": "workforce", "description": "Ajustar batida de ponto. Separada de workforce.edit: corrigir a hora de outra pessoa \xE9 prova, n\xE3o edi\xE7\xE3o."},
166
+ {"key": "workforce.close_period", "plugin": "workforce", "description": "Fechar e reabrir a compet\xEAncia de jornada. Separada de workforce.edit: quem aponta n\xE3o \xE9 quem fecha."}
167
+ ]'::jsonb);
168
+
169
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
170
+ -- 1. O PREVISTO j\xE1 existe \u2014 s\xF3 falta a esp\xE9cie
171
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
172
+ --
173
+ -- \`working_hours\` \xE9 a semana recorrente do PROFISSIONAL, para a agenda saber que
174
+ -- um hor\xE1rio pode existir. \`work_shift\` \xE9 outra coisa: \xE9 o turno de trabalho que
175
+ -- o RH ofertou para uma pessoa num dia, e \xE9 contra ele que o ponto \xE9 conferido.
176
+ -- Reaproveitar \`working_hours\` misturaria "posso ser agendado" com "estou
177
+ -- escalado", que \xE9 justamente a confus\xE3o que o D3 mandou n\xE3o fazer.
178
+ INSERT INTO public.schedule_kinds (kind, plugin, description, status)
179
+ VALUES ('work_shift', 'workforce',
180
+ 'O turno de trabalho que o RH escalou para uma pessoa \u2014 o PREVISTO contra o qual o ponto \xE9 conferido. Distinto de working_hours, que diz apenas quando um hor\xE1rio PODE existir na agenda.',
181
+ 'declared')
182
+ ON CONFLICT (kind) DO UPDATE
183
+ SET plugin = EXCLUDED.plugin, description = EXCLUDED.description, updated_at = now();
184
+
185
+ -- \`public.schedules\` foi scaffoldada em modo \`shadow\` (065): as policies de
186
+ -- autoriza\xE7\xE3o existem, mas as anteriores nunca foram derrubadas, e RLS soma
187
+ -- policies em OR \u2014 uma policy legada sobrevivente alarga tudo silenciosamente.
188
+ -- A partir do momento em que a escala do RH mora aqui dentro, sombra n\xE3o serve:
189
+ -- \`scaffold_set_mode\` reemite com os MESMOS resource_type/plugin j\xE1 registrados
190
+ -- (\`agenda.schedule\` / \`agenda\`) e derruba o que sobrou. N\xE3o \xE9 \`scaffold_table\`:
191
+ -- repetir o scaffold aqui reclassificaria uma tabela do spine para dentro deste
192
+ -- plugin, e a agenda perderia a pr\xF3pria mesa.
193
+ DO $$
194
+ BEGIN
195
+ IF to_regclass('public.schedules') IS NOT NULL
196
+ AND EXISTS (SELECT 1 FROM app.scaffold_registry
197
+ WHERE table_schema = 'public' AND table_name = 'schedules' AND mode = 'shadow') THEN
198
+ PERFORM app.scaffold_set_mode('public.schedules'::regclass, 'enforce');
199
+ END IF;
200
+ END $$;
201
+
202
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
203
+ -- 2. O REALIZADO: a batida, append-only
204
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
205
+
206
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_time_entries (
207
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
208
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
209
+ unit_id uuid REFERENCES app.units(id),
210
+ -- UMA fk, onde o V1 tem person_type + person_id. \`public.people\` j\xE1 \xE9 o
211
+ -- cadastro \xFAnico; profissional e funcion\xE1rio s\xE3o pap\xE9is em person_roles.
212
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,
213
+ -- O nome COMO ESTAVA na hora da batida. N\xE3o \xE9 desnormaliza\xE7\xE3o por pregui\xE7a: a
214
+ -- folha de ponto \xE9 prova, e prova n\xE3o pode mudar porque algu\xE9m corrigiu um
215
+ -- cadastro dois anos depois.
216
+ person_name text,
217
+ -- O evento, n\xE3o o par. \xC9 esta coluna que resolve o intervalo (c): o almo\xE7o \xE9
218
+ -- break_start + break_end, e o dia aceita quantos pares de intervalo houver.
219
+ kind text NOT NULL
220
+ CHECK (kind IN ('clock_in', 'break_start', 'break_end', 'clock_out')),
221
+ punched_at timestamptz NOT NULL,
222
+ -- O DIA DA JORNADA, que n\xE3o \xE9 \`punched_at::date\`. O turno da noite de s\xE1bado
223
+ -- que sai \xE0s 02:10 \xE9 jornada de s\xE1bado; o V1 j\xE1 convive com isso e a pr\xF3pria
224
+ -- caixa de ajuste dele avisa que o registro "cruzou para o dia seguinte".
225
+ work_date date NOT NULL,
226
+ -- Ordena os eventos do dia quando dois caem no mesmo instante (totem com
227
+ -- clique duplo, importa\xE7\xE3o em lote). Sem ele o "\xFAltimo evento do dia" \xE9
228
+ -- indeterminado, e \xE9 ele que diz se o ponto est\xE1 em aberto.
229
+ seq integer NOT NULL DEFAULT 1,
230
+ origin text NOT NULL DEFAULT 'totem'
231
+ CHECK (origin IN ('totem', 'device', 'manual', 'import', 'migration')),
232
+ -- A prova que o totem produz: foto, coordenada com precis\xE3o, semelhan\xE7a
233
+ -- facial, IP e aparelho. Fica em jsonb e n\xE3o em dez colunas porque \xE9 um
234
+ -- conjunto de evid\xEAncia de UM canal de captura \u2014 o totem com AWS Rekognition
235
+ -- do V1 \u2014 e o pr\xF3ximo canal (crach\xE1, biometria local) traz outro conjunto.
236
+ -- O que n\xE3o pode \xE9 perder: no V1 s\xE3o \`photo_path\`, \`lat\`, \`lng\`, \`accuracy_m\`,
237
+ -- \`ip\`, \`user_agent\` e \`face_score\`, medidos em campo como
238
+ -- "-22.89342, -43.39140 \xB7 \xB1 14m" e "Match facial 100.0%".
239
+ evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
240
+ -- O turno que esta batida cumpre, quando h\xE1 um. Nulo \xE9 uma resposta leg\xEDtima
241
+ -- e frequente \u2014 \xE9 literalmente 325 de 325 no tenant medido \u2014 e o V2 prefere
242
+ -- o nulo honesto a um v\xEDnculo inventado.
243
+ assignment_id uuid,
244
+ -- Onde esta hora custa. O turno traz o padr\xE3o (ver plg_workforce_schedule_shifts);
245
+ -- a batida carrega o que de fato foi classificado, porque a hora do cozinheiro
246
+ -- e a do gar\xE7om caem em centros de custo diferentes no mesmo dia.
247
+ -- Sem FK inline: as tr\xEAs colunas abaixo apontam para tabelas de OUTRO plugin,
248
+ -- e a jornada tem de poder ser instalada num cluster que n\xE3o ligou o
249
+ -- financeiro. A FK \xE9 declarada no fim do arquivo, condicionada \xE0 exist\xEAncia do
250
+ -- destino \u2014 presente vira integridade, ausente vira s\xF3 um uuid guardado.
251
+ chart_account_id uuid,
252
+ cost_center_id uuid,
253
+ -- O \`payable_invoice_id\` do V1. No V1 ele \xE9 uma trava DE TELA (preenchido,
254
+ -- o bot\xE3o "Ajustar" some); aqui ele \xE9 uma trava de banco, pela trigger da
255
+ -- se\xE7\xE3o 4. Hora j\xE1 paga n\xE3o se corrige sem reabrir a compet\xEAncia.
256
+ payable_invoice_id uuid,
257
+ notes text,
258
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
259
+ created_at timestamptz NOT NULL DEFAULT now(),
260
+ updated_at timestamptz NOT NULL DEFAULT now(),
261
+ -- A mesma pessoa n\xE3o bate o mesmo evento duas vezes no mesmo instante. \xC9 o
262
+ -- que torna o replay da migra\xE7\xE3o e o clique duplo no totem inofensivos.
263
+ UNIQUE (tenant_id, person_id, work_date, kind, punched_at, seq)
264
+ );
265
+
266
+ COMMENT ON TABLE public.plg_workforce_time_entries IS
267
+ 'A batida, append-only: um EVENTO (entrada, in\xEDcio/fim de intervalo, sa\xEDda), nunca um par edit\xE1vel. '
268
+ 'UPDATE e DELETE s\xE3o recusados por trigger; a corre\xE7\xE3o \xE9 uma linha em plg_workforce_time_adjustments.';
269
+
270
+ -- "O que foi batido neste per\xEDodo, nesta unidade?" \u2014 a pergunta da aba Registros
271
+ -- de Ponto, que o V1 responde com filtros De/At\xE9/Unidade/Pessoa.
272
+ CREATE INDEX IF NOT EXISTS plg_workforce_time_entries_period_idx
273
+ ON public.plg_workforce_time_entries (tenant_id, unit_id, work_date, punched_at);
274
+
275
+ -- "Quais pontos est\xE3o em aberto?" \u2014 20 no tenant medido. Em aberto n\xE3o \xE9 coluna
276
+ -- (seria estado derivado numa tabela append-only): \xE9 o dia em que os eventos de
277
+ -- ABERTURA n\xE3o empatam com os de fechamento. Este \xEDndice serve a metade que a
278
+ -- view varre, e responde sozinho "quem entrou hoje e onde".
279
+ CREATE INDEX IF NOT EXISTS plg_workforce_time_entries_open_idx
280
+ ON public.plg_workforce_time_entries (tenant_id, person_id, work_date, seq)
281
+ WHERE kind IN ('clock_in', 'break_end');
282
+
283
+ -- "O que esta pessoa bateu?" \u2014 a folha individual, e o que a folha de pagamento
284
+ -- soma no fim do m\xEAs.
285
+ CREATE INDEX IF NOT EXISTS plg_workforce_time_entries_person_idx
286
+ ON public.plg_workforce_time_entries (tenant_id, person_id, work_date DESC);
287
+
288
+ DROP TRIGGER IF EXISTS plg_workforce_time_entries_updated_at ON public.plg_workforce_time_entries;
289
+ CREATE TRIGGER plg_workforce_time_entries_updated_at
290
+ BEFORE UPDATE ON public.plg_workforce_time_entries
291
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
292
+
293
+ GRANT SELECT, INSERT ON public.plg_workforce_time_entries TO authenticated;
294
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_entries TO service_role;
295
+
296
+ -- O scaffold \xE9 quem sabe desenhar a pol\xEDtica deste cluster (tenant, unidade,
297
+ -- permiss\xE3o do plugin, escotilhas de compartilhamento). Escrever a policy \xE0 m\xE3o
298
+ -- criaria a segunda regra de autoriza\xE7\xE3o da jornada, que diverge da primeira na
299
+ -- pr\xF3xima mudan\xE7a do scaffold.
300
+ --
301
+ -- \`p_owner_scoping = false\`: a batida n\xE3o tem dono no sentido de "criada por
302
+ -- mim". Quem enxerga ponto de quem \xE9 \`workforce.read\` por unidade.
303
+ SELECT app.scaffold_table('public.plg_workforce_time_entries'::regclass,
304
+ 'workforce.time_entry', 'workforce', false, 'enforce');
305
+
306
+ -- \u2500\u2500 append-only, das duas formas \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
307
+ --
308
+ -- O scaffold acabou de criar policies de UPDATE e DELETE, porque \xE9 isso que ele
309
+ -- faz em toda tabela. Aqui elas n\xE3o deveriam existir: apagar batida \xE9 o que a
310
+ -- lei n\xE3o permite. As duas defesas s\xE3o deliberadamente redundantes \u2014 a policy
311
+ -- some para quem entra por \`authenticated\`, e a trigger pega tamb\xE9m service_role
312
+ -- e qualquer SECURITY DEFINER esquecido.
313
+ DROP POLICY IF EXISTS plg_workforce_time_entries_authz_update ON public.plg_workforce_time_entries;
314
+ DROP POLICY IF EXISTS plg_workforce_time_entries_authz_delete ON public.plg_workforce_time_entries;
315
+
316
+ CREATE OR REPLACE FUNCTION public.plg_workforce_time_entries_append_only()
317
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
318
+ AS $$
319
+ BEGIN
320
+ -- A escotilha \xE9 a mesma que o resto do cluster usa: durante uma onda de
321
+ -- migra\xE7\xE3o o motor marca a sess\xE3o, e s\xF3 ela pode reescrever a linha que ela
322
+ -- mesma acabou de trazer. Fora disso n\xE3o h\xE1 caminho.
323
+ IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN
324
+ RETURN CASE TG_OP WHEN 'DELETE' THEN OLD ELSE NEW END;
325
+ END IF;
326
+ RAISE EXCEPTION 'plg_workforce_time_entries \xE9 append-only (% recusado) \u2014 corrija pelo ajuste, que \xE9 uma linha com autor e motivo', TG_OP
327
+ USING ERRCODE = '42501',
328
+ HINT = 'INSERT em plg_workforce_time_adjustments (action correct|insert|void) exige workforce.adjust_time.';
329
+ END $$;
330
+
331
+ DROP TRIGGER IF EXISTS plg_workforce_time_entries_append_only ON public.plg_workforce_time_entries;
332
+ CREATE TRIGGER plg_workforce_time_entries_append_only
333
+ BEFORE UPDATE OR DELETE ON public.plg_workforce_time_entries
334
+ FOR EACH ROW EXECUTE FUNCTION public.plg_workforce_time_entries_append_only();
335
+
336
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
337
+ -- 3. O AJUSTE \xE9 uma linha, n\xE3o uma coluna
338
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
339
+ --
340
+ -- O V1 guarda o ajuste como quatro colunas na pr\xF3pria batida
341
+ -- (\`checkin_adjusted_at\`, \`checkout_adjusted_at\`, \`adjustment_reason\`,
342
+ -- \`adjusted_by_*\`) \u2014 o que j\xE1 \xE9 melhor que sobrescrever, e \xE9 a parte boa do
343
+ -- modelo dele: *"Os hor\xE1rios reais dos batimentos s\xE3o preservados."* O que ele
344
+ -- n\xE3o tem \xE9 HIST\xD3RICO: o segundo ajuste apaga o primeiro, e ningu\xE9m consegue
345
+ -- dizer quantas vezes aquela hora mudou nem por quem.
346
+ --
347
+ -- Tr\xEAs a\xE7\xF5es, porque o V1 faz as tr\xEAs e chama todas de "ajustar":
348
+ -- correct \u2014 a hora \xE9 outra (o caso comum)
349
+ -- insert \u2014 faltou a batida (o "esqueceu de bater a sa\xEDda" do V1; a batida
350
+ -- nova entra em plg_workforce_time_entries com origin 'manual' e
351
+ -- esta linha \xE9 quem carrega o autor e o motivo dela)
352
+ -- void \u2014 esta batida n\xE3o conta (o clique duplo, o totem testado). N\xE3o \xE9
353
+ -- DELETE: a linha fica, marcada, porque sumir com prova \xE9 o que o
354
+ -- append-only existe para impedir.
355
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_time_adjustments (
356
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
357
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
358
+ unit_id uuid REFERENCES app.units(id),
359
+ time_entry_id uuid NOT NULL
360
+ REFERENCES public.plg_workforce_time_entries(id) ON DELETE CASCADE,
361
+ action text NOT NULL CHECK (action IN ('correct', 'insert', 'void')),
362
+ -- A hora que passa a valer. Obrigat\xF3ria em correct/insert e sem sentido em
363
+ -- void \u2014 a constraint diz isso em vez de deixar para a tela lembrar.
364
+ effective_at timestamptz,
365
+ -- No V1 o motivo \xE9 OPCIONAL ("Motivo (opcional)"), e \xE9 por isso que ningu\xE9m
366
+ -- sabe por que 20 pontos foram mexidos. Aqui \xE9 NOT NULL: um ajuste sem motivo
367
+ -- registrado \xE9 indistingu\xEDvel de uma fraude bem-educada.
368
+ reason text NOT NULL,
369
+ adjusted_by_user_id uuid,
370
+ -- O nome de quem ajustou, congelado \u2014 mesma raz\xE3o de \`person_name\`.
371
+ adjusted_by_name text,
372
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
373
+ created_at timestamptz NOT NULL DEFAULT now(),
374
+ updated_at timestamptz NOT NULL DEFAULT now(),
375
+ CONSTRAINT plg_workforce_time_adjustments_effective_check
376
+ CHECK ((action = 'void' AND effective_at IS NULL) OR (action <> 'void' AND effective_at IS NOT NULL)),
377
+ CONSTRAINT plg_workforce_time_adjustments_reason_check
378
+ CHECK (btrim(reason) <> '')
379
+ );
380
+
381
+ COMMENT ON TABLE public.plg_workforce_time_adjustments IS
382
+ 'O ajuste de ponto como LINHA, com autor e motivo obrigat\xF3rio. O V1 guarda isso em colunas da pr\xF3pria batida e perde o hist\xF3rico no segundo ajuste; aqui cada corre\xE7\xE3o fica, e a hora que vale \xE9 a da \xFAltima n\xE3o anulada.';
383
+
384
+ -- "Qual a hora que vale nesta batida?" \u2014 a view da se\xE7\xE3o 7 pergunta isso uma vez
385
+ -- por batida, e \xE9 o caminho quente da folha de ponto inteira.
386
+ CREATE INDEX IF NOT EXISTS plg_workforce_time_adjustments_entry_idx
387
+ ON public.plg_workforce_time_adjustments (time_entry_id, created_at DESC);
388
+
389
+ DROP TRIGGER IF EXISTS plg_workforce_time_adjustments_updated_at ON public.plg_workforce_time_adjustments;
390
+ CREATE TRIGGER plg_workforce_time_adjustments_updated_at
391
+ BEFORE UPDATE ON public.plg_workforce_time_adjustments
392
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
393
+
394
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_adjustments TO authenticated, service_role;
395
+
396
+ SELECT app.scaffold_table('public.plg_workforce_time_adjustments'::regclass,
397
+ 'workforce.time_adjustment', 'workforce', false, 'enforce');
398
+
399
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
400
+ -- 4. A COMPET\xCANCIA (lacuna a)
401
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
402
+ --
403
+ -- Mesmo desenho de \`plg_financial_periods\` (plugin-financial/006), de
404
+ -- prop\xF3sito: a empresa fecha UM m\xEAs, e ter dois calend\xE1rios de fechamento com
405
+ -- regras diferentes \u2014 um do dinheiro e outro das horas \u2014 \xE9 como n\xE3o ter
406
+ -- fechamento nenhum, porque a pergunta "fevereiro est\xE1 fechado?" passa a ter
407
+ -- duas respostas.
408
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_periods (
409
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
410
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
411
+ -- A compet\xEAncia \xE9 do TENANT, n\xE3o da unidade: fechar o m\xEAs \xE9 um ato de
412
+ -- empresa, e um m\xEAs meio fechado \xE9 a pior resposta para quem precisa dizer ao
413
+ -- contador que acabou. A coluna existe porque o scaffold precisa dela.
414
+ unit_id uuid REFERENCES app.units(id),
415
+ -- Sempre o dia 1\xBA, por constraint e n\xE3o por conven\xE7\xE3o de quem escreve.
416
+ competence_month date NOT NULL,
417
+ status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),
418
+ closed_at timestamptz,
419
+ closed_by_user_id uuid,
420
+ reopened_at timestamptz,
421
+ reopened_by_user_id uuid,
422
+ -- Reabrir sem dizer por qu\xEA \xE9 como n\xE3o ter fechado. Obrigat\xF3rio na RPC; a
423
+ -- coluna aceita nulo porque um per\xEDodo nunca reaberto n\xE3o tem um.
424
+ reopen_reason text,
425
+ -- O que o fechamento virou no financeiro, quando virou. A folha \xE9 uma conta a
426
+ -- pagar como outra qualquer, e a classifica\xE7\xE3o dela mora aqui porque \xE9 o
427
+ -- fechamento \u2014 n\xE3o a batida \u2014 que produz o lan\xE7amento.
428
+ payable_invoice_id uuid,
429
+ chart_account_id uuid,
430
+ cost_center_id uuid,
431
+ notes text,
432
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
433
+ created_at timestamptz NOT NULL DEFAULT now(),
434
+ updated_at timestamptz NOT NULL DEFAULT now(),
435
+ CONSTRAINT plg_workforce_periods_first_of_month_check
436
+ CHECK (competence_month = date_trunc('month', competence_month)::date),
437
+ CONSTRAINT plg_workforce_periods_tenant_month_key UNIQUE (tenant_id, competence_month)
438
+ );
439
+
440
+ COMMENT ON TABLE public.plg_workforce_periods IS
441
+ 'Compet\xEAncia mensal da jornada e seu estado. Fechada = nenhuma batida nem ajuste entra com data naquele m\xEAs. Escrita s\xF3 por workforce_period_close/reopen.';
442
+
443
+ -- A trava consulta "este m\xEAs est\xE1 fechado?" a cada batida: \xEDndice parcial.
444
+ CREATE INDEX IF NOT EXISTS plg_workforce_periods_closed_idx
445
+ ON public.plg_workforce_periods (tenant_id, competence_month) WHERE status = 'closed';
446
+
447
+ DROP TRIGGER IF EXISTS plg_workforce_periods_updated_at ON public.plg_workforce_periods;
448
+ CREATE TRIGGER plg_workforce_periods_updated_at
449
+ BEFORE UPDATE ON public.plg_workforce_periods
450
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
451
+
452
+ GRANT SELECT ON public.plg_workforce_periods TO authenticated;
453
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_periods TO service_role;
454
+
455
+ SELECT app.scaffold_table('public.plg_workforce_periods'::regclass,
456
+ 'workforce.period', 'workforce', false, 'enforce');
457
+
458
+ -- ESCREVER \xC9 S\xD3 PELA RPC. Fechar e reabrir s\xE3o ATOS, n\xE3o edi\xE7\xF5es de linha, e o
459
+ -- ato \xE9 onde a permiss\xE3o e o registro de QUEM moram. Uma policy de UPDATE aqui
460
+ -- seria a porta dos fundos da trava \u2014 \xE9 a mesma decis\xE3o do financeiro/006.
461
+ DROP POLICY IF EXISTS plg_workforce_periods_authz_insert ON public.plg_workforce_periods;
462
+ DROP POLICY IF EXISTS plg_workforce_periods_authz_update ON public.plg_workforce_periods;
463
+ DROP POLICY IF EXISTS plg_workforce_periods_authz_delete ON public.plg_workforce_periods;
464
+
465
+ CREATE OR REPLACE FUNCTION public.plg_workforce_period_is_closed(p_tenant uuid, p_date date)
466
+ RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
467
+ AS $$
468
+ SELECT EXISTS (
469
+ SELECT 1 FROM public.plg_workforce_periods p
470
+ WHERE p.tenant_id = p_tenant
471
+ AND p.status = 'closed'
472
+ AND p.competence_month = date_trunc('month', p_date)::date);
473
+ $$;
474
+
475
+ COMMENT ON FUNCTION public.plg_workforce_period_is_closed(uuid, date) IS
476
+ 'O m\xEAs desta data est\xE1 fechado para a jornada deste tenant?';
477
+
478
+ -- \u2500\u2500 a trava sobre a batida \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
479
+ CREATE OR REPLACE FUNCTION public.trg_workforce_refuse_closed_period()
480
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
481
+ AS $$
482
+ DECLARE
483
+ v_date date;
484
+ BEGIN
485
+ -- Uma onda de migra\xE7\xE3o traz o passado inteiro, e o passado inteiro est\xE1 em
486
+ -- meses que o V1 j\xE1 tratou como encerrados. Fechar por cima do que ainda est\xE1
487
+ -- chegando seria fechar antes de existir.
488
+ IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN
489
+ RETURN NEW;
490
+ END IF;
491
+
492
+ -- A data que conta \xE9 a da JORNADA, n\xE3o a de cria\xE7\xE3o da linha.
493
+ IF TG_TABLE_NAME = 'plg_workforce_time_entries' THEN
494
+ v_date := NEW.work_date;
495
+ ELSE
496
+ SELECT e.work_date INTO v_date
497
+ FROM public.plg_workforce_time_entries e WHERE e.id = NEW.time_entry_id;
498
+ END IF;
499
+
500
+ IF v_date IS NOT NULL AND public.plg_workforce_period_is_closed(NEW.tenant_id, v_date) THEN
501
+ RAISE EXCEPTION 'a compet\xEAncia de % est\xE1 fechada \u2014 reabra o per\xEDodo para lan\xE7ar nela', to_char(v_date, 'MM/YYYY')
502
+ USING ERRCODE = '23514',
503
+ HINT = 'workforce_period_reopen(date_trunc(''month'', data)::date, ''motivo'') exige workforce.close_period.';
504
+ END IF;
505
+
506
+ RETURN NEW;
507
+ END $$;
508
+
509
+ DROP TRIGGER IF EXISTS plg_workforce_time_entries_refuse_closed_period ON public.plg_workforce_time_entries;
510
+ CREATE TRIGGER plg_workforce_time_entries_refuse_closed_period
511
+ BEFORE INSERT ON public.plg_workforce_time_entries
512
+ FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();
513
+
514
+ DROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_closed_period ON public.plg_workforce_time_adjustments;
515
+ CREATE TRIGGER plg_workforce_time_adjustments_refuse_closed_period
516
+ BEFORE INSERT OR UPDATE ON public.plg_workforce_time_adjustments
517
+ FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();
518
+
519
+ -- \u2500\u2500 a segunda trava do V1, agora no banco \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
520
+ --
521
+ -- No V1, batida com \`payable_invoice_id\` preenchido n\xE3o pode ser ajustada \u2014 e a
522
+ -- regra vive no componente que esconde o bot\xE3o. Aqui ela vive onde n\xE3o d\xE1 para
523
+ -- contornar. A linha migrada \xE9 coberta pela mesma trava: o V1 aponta para uma
524
+ -- tabela de t\xEDtulos cuja contraparte no V2 n\xE3o \xE9 a mesma coisa (ver a nota do
525
+ -- de-para), ent\xE3o a refer\xEAncia crua fica em \`metadata.v1.payable_invoice_id\` \u2014 e
526
+ -- basta ela existir para a hora estar travada.
527
+ CREATE OR REPLACE FUNCTION public.trg_workforce_refuse_settled_adjustment()
528
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
529
+ AS $$
530
+ DECLARE
531
+ v_entry public.plg_workforce_time_entries%ROWTYPE;
532
+ BEGIN
533
+ IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN
534
+ RETURN NEW;
535
+ END IF;
536
+ SELECT * INTO v_entry FROM public.plg_workforce_time_entries WHERE id = NEW.time_entry_id;
537
+ IF v_entry.payable_invoice_id IS NOT NULL
538
+ OR (v_entry.metadata #>> '{v1,payable_invoice_id}') IS NOT NULL THEN
539
+ RAISE EXCEPTION 'esta batida j\xE1 virou t\xEDtulo a pagar \u2014 a hora paga n\xE3o se corrige sem estornar o t\xEDtulo'
540
+ USING ERRCODE = '23514';
541
+ END IF;
542
+ IF NOT app.has_permission('workforce.adjust_time', v_entry.unit_id) THEN
543
+ RAISE EXCEPTION 'ajustar ponto exige workforce.adjust_time' USING ERRCODE = '42501';
544
+ END IF;
545
+ RETURN NEW;
546
+ END $$;
547
+
548
+ DROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_settled ON public.plg_workforce_time_adjustments;
549
+ CREATE TRIGGER plg_workforce_time_adjustments_refuse_settled
550
+ BEFORE INSERT ON public.plg_workforce_time_adjustments
551
+ FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_settled_adjustment();
552
+
553
+ -- \u2500\u2500 fechar e reabrir \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
554
+ CREATE OR REPLACE FUNCTION public.workforce_period_close(p_month date, p_notes text DEFAULT NULL)
555
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
556
+ AS $$
557
+ DECLARE
558
+ v_tenant uuid := app.current_tenant_id();
559
+ v_month date := date_trunc('month', p_month)::date;
560
+ v_open bigint;
561
+ v_row public.plg_workforce_periods%ROWTYPE;
562
+ BEGIN
563
+ IF v_tenant IS NULL THEN
564
+ RAISE EXCEPTION 'workforce_period_close: sem tenant na sess\xE3o' USING ERRCODE = '42501';
565
+ END IF;
566
+ IF NOT app.has_permission('workforce.close_period', NULL) THEN
567
+ RAISE EXCEPTION 'workforce_period_close: exige workforce.close_period' USING ERRCODE = '42501';
568
+ END IF;
569
+ IF v_month > date_trunc('month', current_date)::date THEN
570
+ RAISE EXCEPTION 'workforce_period_close: % ainda n\xE3o terminou', to_char(v_month, 'MM/YYYY')
571
+ USING ERRCODE = '22023';
572
+ END IF;
573
+
574
+ -- Ponto em aberto \xE9 jornada que ainda vai mudar. Fechar por cima produz um
575
+ -- total de horas que muda depois de fechado, que \xE9 exatamente o que a trava
576
+ -- existe para impedir \u2014 e \xE9 o defeito medido no V1: 20 batimentos em aberto
577
+ -- fazem o valor do per\xEDodo (R$ 47.747) ser um n\xFAmero menor que o real.
578
+ SELECT count(*) INTO v_open
579
+ FROM public.v_workforce_day d
580
+ WHERE d.tenant_id = v_tenant
581
+ AND d.is_open
582
+ AND date_trunc('month', d.work_date)::date <= v_month;
583
+ IF v_open > 0 THEN
584
+ RAISE EXCEPTION 'workforce_period_close: % ponto(s) em aberto em % ou antes \u2014 feche ou ajuste antes', v_open, to_char(v_month, 'MM/YYYY')
585
+ USING ERRCODE = '23514',
586
+ HINT = 'SELECT * FROM public.v_workforce_day WHERE is_open;';
587
+ END IF;
588
+
589
+ INSERT INTO public.plg_workforce_periods (tenant_id, competence_month, status, closed_at, closed_by_user_id, notes)
590
+ VALUES (v_tenant, v_month, 'closed', now(), auth.uid(), p_notes)
591
+ ON CONFLICT (tenant_id, competence_month) DO UPDATE
592
+ SET status = 'closed', closed_at = now(), closed_by_user_id = auth.uid(),
593
+ notes = coalesce(EXCLUDED.notes, public.plg_workforce_periods.notes), updated_at = now()
594
+ RETURNING * INTO v_row;
595
+
596
+ RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,
597
+ 'status', 'closed', 'closed_at', v_row.closed_at);
598
+ END $$;
599
+
600
+ CREATE OR REPLACE FUNCTION public.workforce_period_reopen(p_month date, p_reason text)
601
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
602
+ AS $$
603
+ DECLARE
604
+ v_tenant uuid := app.current_tenant_id();
605
+ v_month date := date_trunc('month', p_month)::date;
606
+ v_row public.plg_workforce_periods%ROWTYPE;
607
+ BEGIN
608
+ IF v_tenant IS NULL THEN
609
+ RAISE EXCEPTION 'workforce_period_reopen: sem tenant na sess\xE3o' USING ERRCODE = '42501';
610
+ END IF;
611
+ IF NOT app.has_permission('workforce.close_period', NULL) THEN
612
+ RAISE EXCEPTION 'workforce_period_reopen: exige workforce.close_period' USING ERRCODE = '42501';
613
+ END IF;
614
+ IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN
615
+ RAISE EXCEPTION 'workforce_period_reopen: o motivo \xE9 obrigat\xF3rio' USING ERRCODE = '22023';
616
+ END IF;
617
+
618
+ UPDATE public.plg_workforce_periods SET
619
+ status = 'open', reopened_at = now(), reopened_by_user_id = auth.uid(),
620
+ reopen_reason = p_reason, updated_at = now()
621
+ WHERE tenant_id = v_tenant AND competence_month = v_month
622
+ RETURNING * INTO v_row;
623
+
624
+ IF v_row.id IS NULL THEN
625
+ RAISE EXCEPTION 'workforce_period_reopen: % nunca foi fechado', to_char(v_month, 'MM/YYYY')
626
+ USING ERRCODE = 'P0002';
627
+ END IF;
628
+
629
+ RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,
630
+ 'status', 'open', 'reopened_at', v_row.reopened_at, 'reason', p_reason);
631
+ END $$;
632
+
633
+ REVOKE ALL ON FUNCTION public.workforce_period_close(date, text) FROM PUBLIC;
634
+ REVOKE ALL ON FUNCTION public.workforce_period_reopen(date, text) FROM PUBLIC;
635
+ GRANT EXECUTE ON FUNCTION public.workforce_period_close(date, text) TO authenticated;
636
+ GRANT EXECUTE ON FUNCTION public.workforce_period_reopen(date, text) TO authenticated;
637
+
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
+ -- 5. A ESCALA: o molde
640
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
641
+
642
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_templates (
643
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
644
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
645
+ -- No V1 o template \xE9 POR UNIDADE (o editor abre com um seletor de Unidade), e
646
+ -- aqui tamb\xE9m: "Almo\xE7o Seg. a Sex." da matriz n\xE3o \xE9 o da filial.
647
+ unit_id uuid REFERENCES app.units(id),
648
+ name text NOT NULL,
649
+ is_active boolean NOT NULL DEFAULT true,
650
+ notes text,
651
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
652
+ created_at timestamptz NOT NULL DEFAULT now(),
653
+ updated_at timestamptz NOT NULL DEFAULT now()
654
+ );
655
+
656
+ COMMENT ON TABLE public.plg_workforce_schedule_templates IS
657
+ 'O molde da escala. Pr\xE9-requisito duro no V1: sem template n\xE3o se cria escala nenhuma \u2014 "Selecione um template de escala acima para come\xE7ar."';
658
+
659
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_shifts (
660
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
661
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
662
+ unit_id uuid REFERENCES app.units(id),
663
+ template_id uuid NOT NULL
664
+ REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,
665
+ label text NOT NULL,
666
+ start_time time NOT NULL,
667
+ end_time time NOT NULL,
668
+ -- 0 = domingo, como \`extract(dow)\` e como \`public.schedules.day_of_week\`.
669
+ -- A constraint existe porque um 7 solto num array \xE9 um turno que nunca
670
+ -- acontece e que ningu\xE9m percebe faltando.
671
+ weekdays smallint[] NOT NULL DEFAULT '{}'::smallint[],
672
+ -- Turno que vira o dia (16:00\u201302:00) \xE9 normal em restaurante, e \xE9 a raz\xE3o de
673
+ -- \`work_date\` ser coluna na batida. Guardar a travessia aqui evita que cada
674
+ -- leitor redescubra \`end_time < start_time\` \xE0 sua maneira.
675
+ crosses_midnight boolean NOT NULL GENERATED ALWAYS AS (end_time <= start_time) STORED,
676
+ sort_order integer NOT NULL DEFAULT 0,
677
+ -- O padr\xE3o de onde a hora deste turno custa. A batida copia e pode divergir.
678
+ -- FK condicional no fim do arquivo, como nas outras duas tabelas.
679
+ chart_account_id uuid,
680
+ cost_center_id uuid,
681
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
682
+ created_at timestamptz NOT NULL DEFAULT now(),
683
+ updated_at timestamptz NOT NULL DEFAULT now(),
684
+ CONSTRAINT plg_workforce_schedule_shifts_weekdays_check
685
+ CHECK (weekdays <@ ARRAY[0,1,2,3,4,5,6]::smallint[])
686
+ );
687
+
688
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_requirements (
689
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
690
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
691
+ unit_id uuid REFERENCES app.units(id),
692
+ shift_id uuid NOT NULL
693
+ REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,
694
+ -- Texto, e n\xE3o FK para um cadastro de profiss\xE3o: no V1 isto \xE9 o nome livre que
695
+ -- a tela mostra ("Gar\xE7om (1/1)", "Cozinha (0/3)"), e o V2 ainda n\xE3o tem o
696
+ -- cadastro de profiss\xE3o que seria o destino honesto. Inventar a FK agora seria
697
+ -- criar um cadastro a partir de dado sujo; o texto atravessa e fica audit\xE1vel.
698
+ profession_name text NOT NULL,
699
+ required_count integer NOT NULL DEFAULT 1 CHECK (required_count > 0),
700
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
701
+ created_at timestamptz NOT NULL DEFAULT now(),
702
+ updated_at timestamptz NOT NULL DEFAULT now(),
703
+ UNIQUE (shift_id, profession_name)
704
+ );
705
+
706
+ COMMENT ON COLUMN public.plg_workforce_schedule_requirements.required_count IS
707
+ 'A lota\xE7\xE3o da fun\xE7\xE3o no turno \u2014 o denominador do "Gar\xE7om (1/1)" da tela. \xC9 contra ele que a posi\xE7\xE3o por prioridade vira titular ou stand-by.';
708
+
709
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_template_assignments (
710
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
711
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
712
+ unit_id uuid REFERENCES app.units(id),
713
+ template_id uuid NOT NULL
714
+ REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,
715
+ shift_id uuid NOT NULL
716
+ REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,
717
+ profession_name text NOT NULL,
718
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
719
+ -- COLUNA, e n\xE3o posi\xE7\xE3o. No V1 titular vs stand-by no molde \xE9 impl\xEDcito \u2014 s\xE3o
720
+ -- titulares os primeiros \`required_count\` por \`priority\` \u2014 e essa regra s\xF3
721
+ -- existe no c\xF3digo da tela. Aqui ela \xE9 calculada uma vez, na entrada, e fica
722
+ -- escrita: a mesma verdade que a inst\xE2ncia j\xE1 grava.
723
+ role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),
724
+ priority integer NOT NULL DEFAULT 0,
725
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
726
+ created_at timestamptz NOT NULL DEFAULT now(),
727
+ updated_at timestamptz NOT NULL DEFAULT now(),
728
+ UNIQUE (shift_id, profession_name, person_id)
729
+ );
730
+
731
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
732
+ -- 6. A ESCALA: a inst\xE2ncia do dia
733
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
734
+
735
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedules (
736
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
737
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
738
+ unit_id uuid REFERENCES app.units(id),
739
+ schedule_date date NOT NULL,
740
+ -- SET NULL e n\xE3o CASCADE: apagar o molde n\xE3o pode apagar a escala de ter\xE7a
741
+ -- passada, que \xE9 registro de quem trabalhou. A c\xF3pia do turno j\xE1 est\xE1 no
742
+ -- snapshot da atribui\xE7\xE3o, ent\xE3o a escala sobrevive sozinha ao molde.
743
+ template_id uuid REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE SET NULL,
744
+ status text NOT NULL DEFAULT 'draft'
745
+ CHECK (status IN ('draft', 'published', 'closed')),
746
+ published_at timestamptz,
747
+ published_by_user_id uuid,
748
+ is_special_day boolean NOT NULL DEFAULT false,
749
+ special_day_label text,
750
+ notes text,
751
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
752
+ created_at timestamptz NOT NULL DEFAULT now(),
753
+ updated_at timestamptz NOT NULL DEFAULT now(),
754
+ -- Publicada sem carimbo de quando \xE9 uma escala que ningu\xE9m consegue provar
755
+ -- ter avisado \u2014 e avisar \xE9 a \xFAnica coisa que a publica\xE7\xE3o faz.
756
+ CONSTRAINT plg_workforce_schedules_published_check
757
+ CHECK (status <> 'published' OR published_at IS NOT NULL)
758
+ );
759
+
760
+ -- "Qual a escala deste dia?" \u2014 \xE9 o clique do calend\xE1rio, e \xE9 a consulta que a
761
+ -- tela faz primeiro em toda sess\xE3o.
762
+ CREATE INDEX IF NOT EXISTS plg_workforce_schedules_day_idx
763
+ ON public.plg_workforce_schedules (tenant_id, unit_id, schedule_date DESC, status);
764
+
765
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_assignments (
766
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
767
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
768
+ unit_id uuid REFERENCES app.units(id),
769
+ schedule_id uuid NOT NULL
770
+ REFERENCES public.plg_workforce_schedules(id) ON DELETE CASCADE,
771
+ -- A refer\xEAncia ao turno do molde \xE9 conveni\xEAncia, n\xE3o depend\xEAncia: o snapshot
772
+ -- abaixo \xE9 quem manda. Por isso SET NULL.
773
+ shift_id uuid REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE SET NULL,
774
+ -- SNAPSHOT, copiado na cria\xE7\xE3o da escala, exatamente como o V1 faz. Mudar o
775
+ -- molde n\xE3o pode reescrever a escala de anteontem: o que a pessoa foi chamada
776
+ -- para cumprir \xE9 um fato daquele dia, e \xE9 contra ESTE hor\xE1rio que o ponto dela
777
+ -- \xE9 conferido.
778
+ shift_label text,
779
+ shift_start_time time,
780
+ shift_end_time time,
781
+ profession_name text,
782
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,
783
+ role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),
784
+ priority integer NOT NULL DEFAULT 0,
785
+ status text NOT NULL DEFAULT 'pending'
786
+ CHECK (status IN ('pending', 'notified', 'confirmed', 'declined', 'realized')),
787
+ -- O convite que sai por link, sem sess\xE3o. \xDAnico no tenant porque \xE9 uma
788
+ -- credencial: dois convites com o mesmo token s\xE3o duas pessoas confirmando
789
+ -- pelo mesmo endere\xE7o.
790
+ response_token text,
791
+ response_channel text,
792
+ notified_at timestamptz,
793
+ responded_at timestamptz,
794
+ -- A PONTE COM A AGENDA, e a raz\xE3o de a escala n\xE3o morar l\xE1. O RH oferta; ao
795
+ -- publicar, a atribui\xE7\xE3o materializa uma linha de \`public.schedules\` com
796
+ -- kind 'work_shift' e a agenda consome. A coluna \xE9 o que torna o cruzamento
797
+ -- previsto \xD7 realizado uma chave em vez de um casamento por nome e hora.
798
+ schedule_row_id uuid REFERENCES public.schedules(id) ON DELETE SET NULL,
799
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
800
+ created_at timestamptz NOT NULL DEFAULT now(),
801
+ updated_at timestamptz NOT NULL DEFAULT now(),
802
+ -- A mesma pessoa n\xE3o ocupa a mesma vaga duas vezes no mesmo dia. No V1 nada
803
+ -- impede, e o resultado \xE9 a lota\xE7\xE3o "2/1" que a tela n\xE3o sabe desenhar.
804
+ UNIQUE (schedule_id, shift_id, profession_name, person_id)
805
+ );
806
+
807
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_token_uq
808
+ ON public.plg_workforce_schedule_assignments (tenant_id, response_token)
809
+ WHERE response_token IS NOT NULL;
810
+
811
+ -- "Quem est\xE1 escalado hoje, e quem ainda n\xE3o respondeu?" \u2014 os contadores
812
+ -- "N titulares \xB7 N pendentes" do cart\xE3o da escala.
813
+ CREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_schedule_idx
814
+ ON public.plg_workforce_schedule_assignments (schedule_id, role, status);
815
+
816
+ -- "O que ESTA pessoa tem pela frente?" \u2014 a folha individual e o convite.
817
+ CREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_person_idx
818
+ ON public.plg_workforce_schedule_assignments (tenant_id, person_id, status);
819
+
820
+ -- O log da atribui\xE7\xE3o: quem mudou o qu\xEA, por qual canal. No V1 \xE9
821
+ -- \`schedule_assignment_logs\`, e \xE9 a \xFAnica parte do modelo de escala dele que j\xE1
822
+ -- nasceu append-only.
823
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_assignment_logs (
824
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
825
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
826
+ unit_id uuid REFERENCES app.units(id),
827
+ assignment_id uuid NOT NULL
828
+ REFERENCES public.plg_workforce_schedule_assignments(id) ON DELETE CASCADE,
829
+ from_status text,
830
+ to_status text NOT NULL,
831
+ channel text,
832
+ note text,
833
+ changed_by_user_id uuid,
834
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
835
+ created_at timestamptz NOT NULL DEFAULT now(),
836
+ updated_at timestamptz NOT NULL DEFAULT now()
837
+ );
838
+
839
+ CREATE INDEX IF NOT EXISTS plg_workforce_assignment_logs_assignment_idx
840
+ ON public.plg_workforce_assignment_logs (assignment_id, created_at DESC);
841
+
842
+ -- \u2500\u2500 triggers, grants e scaffold das seis tabelas de escala \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
843
+ --
844
+ -- Em la\xE7o porque s\xE3o seis vezes a mesma disciplina: o gatilho de updated_at, o
845
+ -- GRANT para os dois pap\xE9is que existem, e o scaffold desenhando a policy. Seis
846
+ -- blocos copiados divergiriam no primeiro dia em que algu\xE9m editasse um s\xF3.
847
+ DO $$
848
+ DECLARE
849
+ r record;
850
+ BEGIN
851
+ FOR r IN
852
+ SELECT * FROM (VALUES
853
+ ('plg_workforce_schedule_templates', 'workforce.schedule_template'),
854
+ ('plg_workforce_schedule_shifts', 'workforce.schedule_shift'),
855
+ ('plg_workforce_schedule_requirements', 'workforce.schedule_requirement'),
856
+ ('plg_workforce_schedule_template_assignments', 'workforce.schedule_template_assignment'),
857
+ ('plg_workforce_schedules', 'workforce.schedule'),
858
+ ('plg_workforce_schedule_assignments', 'workforce.schedule_assignment'),
859
+ ('plg_workforce_assignment_logs', 'workforce.assignment_log')
860
+ ) AS t(tbl, resource)
861
+ LOOP
862
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);
863
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',
864
+ r.tbl || '_updated_at', r.tbl);
865
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated, service_role', r.tbl);
866
+ PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce', false, 'enforce');
867
+ END LOOP;
868
+ END $$;
869
+
870
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
871
+ -- 7. O CRUZAMENTO previsto \xD7 realizado (lacuna b)
872
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
873
+
874
+ -- A hora que VALE em cada batida: a \xFAltima corre\xE7\xE3o n\xE3o anulada, ou o hor\xE1rio
875
+ -- real. \xC9 a leitura que substitui as colunas \`*_adjusted_at\` do V1 sem perder a
876
+ -- propriedade que o V1 acertou \u2014 o hor\xE1rio real continua ali, intocado.
877
+ --
878
+ -- \`security_invoker\` em todas as views desta se\xE7\xE3o: uma folha de ponto que
879
+ -- ignora escopo \xE9 um vazamento com totais.
880
+ -- REPLAY: \`DROP\` e depois \`CREATE\`, e n\xE3o \`CREATE OR REPLACE\`.
881
+ --
882
+ -- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,
883
+ -- \`CREATE OR REPLACE\` faz; TIRAR, n\xE3o faz \u2014 e \xE9 o que a baseline faria numa
884
+ -- segunda passada, porque a vers\xE3o dela \xE9 a estreita. O cluster devolve
885
+ -- \`42P16: cannot drop columns from view\` e a corrente para aqui.
886
+ --
887
+ -- Conferido em \`pg_depend\` antes de derrubar: nada pende destas duas views,
888
+ -- ent\xE3o o \`DROP\` \xE9 sem \`CASCADE\` \u2014 de prop\xF3sito. Sem ele, o dia em que
889
+ -- algu\xE9m construir sobre elas o \`DROP\` falha alto em vez de levar junto.
890
+ -- Na ordem da depend\xEAncia: \`v_workforce_day\` \xE9 constru\xEDda SOBRE esta, ent\xE3o
891
+ -- sai primeiro. Sem isso o \`DROP\` de baixo recusa \u2014 e recusar \xE9 o certo,
892
+ -- foi assim que esta depend\xEAncia apareceu.
893
+ DROP VIEW IF EXISTS public.v_workforce_day;
894
+ DROP VIEW IF EXISTS public.v_workforce_time_entries;
895
+ CREATE VIEW public.v_workforce_time_entries
896
+ WITH (security_invoker = 'true') AS
897
+ SELECT e.id,
898
+ e.tenant_id,
899
+ e.unit_id,
900
+ e.person_id,
901
+ e.person_name,
902
+ e.kind,
903
+ e.work_date,
904
+ e.seq,
905
+ e.origin,
906
+ e.punched_at,
907
+ coalesce(c.effective_at, e.punched_at) AS effective_at,
908
+ (c.id IS NOT NULL) AS adjusted,
909
+ (v.id IS NOT NULL) AS voided,
910
+ c.reason AS adjustment_reason,
911
+ c.adjusted_by_name,
912
+ e.assignment_id,
913
+ e.payable_invoice_id,
914
+ e.evidence,
915
+ e.metadata
916
+ FROM public.plg_workforce_time_entries e
917
+ LEFT JOIN LATERAL (
918
+ SELECT a.id, a.effective_at, a.reason, a.adjusted_by_name
919
+ FROM public.plg_workforce_time_adjustments a
920
+ WHERE a.time_entry_id = e.id AND a.action IN ('correct', 'insert')
921
+ ORDER BY a.created_at DESC, a.id DESC
922
+ LIMIT 1) c ON true
923
+ LEFT JOIN LATERAL (
924
+ SELECT a.id FROM public.plg_workforce_time_adjustments a
925
+ WHERE a.time_entry_id = e.id AND a.action = 'void'
926
+ LIMIT 1) v ON true;
927
+
928
+ COMMENT ON VIEW public.v_workforce_time_entries IS
929
+ 'A batida com a hora que vale: a \xFAltima corre\xE7\xE3o n\xE3o anulada, ou o hor\xE1rio real. O real (punched_at) nunca some \u2014 \xE9 o que torna o ponto audit\xE1vel.';
930
+
931
+ -- O dia de trabalho de uma pessoa, previsto ao lado de realizado.
932
+ --
933
+ -- FULL OUTER JOIN de prop\xF3sito. As duas assimetrias s\xE3o exatamente o que o V1
934
+ -- n\xE3o consegue mostrar, e s\xE3o as duas perguntas do RH: previsto sem realizado \xE9
935
+ -- FALTA; realizado sem previsto \xE9 FORA DA ESCALA (que no tenant medido \xE9 o
936
+ -- estado de 325 de 325 registros \u2014 n\xE3o porque as pessoas trabalhem fora da
937
+ -- escala, mas porque o ponto est\xE1 ligado numa unidade e a escala em outra).
938
+ --
939
+ -- As horas trabalhadas saem de uma soma com sinal: clock_out e break_start
940
+ -- FECHAM um trecho, clock_in e break_end ABREM. Quantos intervalos houver no
941
+ -- dia, a conta \xE9 a mesma \u2014 \xE9 isso que o par \xFAnico do V1 n\xE3o consegue fazer.
942
+ -- REPLAY: \`DROP\` e depois \`CREATE\`, e n\xE3o \`CREATE OR REPLACE\`.
943
+ --
944
+ -- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,
945
+ -- \`CREATE OR REPLACE\` faz; TIRAR, n\xE3o faz \u2014 e \xE9 o que a baseline faria numa
946
+ -- segunda passada, porque a vers\xE3o dela \xE9 a estreita. O cluster devolve
947
+ -- \`42P16: cannot drop columns from view\` e a corrente para aqui.
948
+ --
949
+ -- Conferido em \`pg_depend\` antes de derrubar: nada pende destas duas views,
950
+ -- ent\xE3o o \`DROP\` \xE9 sem \`CASCADE\` \u2014 de prop\xF3sito. Sem ele, o dia em que
951
+ -- algu\xE9m construir sobre elas o \`DROP\` falha alto em vez de levar junto.
952
+ DROP VIEW IF EXISTS public.v_workforce_day;
953
+ CREATE VIEW public.v_workforce_day
954
+ WITH (security_invoker = 'true') AS
955
+ WITH realized AS (
956
+ SELECT e.tenant_id,
957
+ e.unit_id,
958
+ e.person_id,
959
+ e.work_date,
960
+ min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,
961
+ max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,
962
+ count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,
963
+ count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end'))
964
+ <> count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS is_open,
965
+ sum(CASE
966
+ WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)
967
+ WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)
968
+ END) AS worked_seconds
969
+ FROM public.v_workforce_time_entries e
970
+ WHERE NOT e.voided
971
+ GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date
972
+ ),
973
+ planned AS (
974
+ SELECT a.tenant_id,
975
+ a.unit_id,
976
+ a.person_id,
977
+ s.schedule_date AS work_date,
978
+ a.id AS assignment_id,
979
+ a.role,
980
+ a.status AS assignment_status,
981
+ a.shift_label,
982
+ a.shift_start_time,
983
+ a.shift_end_time,
984
+ -- O turno que vira o dia \xE9 normal aqui; sem o +24h a jornada da noite
985
+ -- daria negativa.
986
+ (extract(epoch FROM a.shift_end_time - a.shift_start_time)
987
+ + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds
988
+ FROM public.plg_workforce_schedule_assignments a
989
+ JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id
990
+ WHERE s.status IN ('published', 'closed')
991
+ AND a.role = 'titular'
992
+ AND a.shift_start_time IS NOT NULL
993
+ AND a.shift_end_time IS NOT NULL
994
+ )
995
+ SELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,
996
+ coalesce(p.unit_id, r.unit_id) AS unit_id,
997
+ coalesce(p.person_id, r.person_id) AS person_id,
998
+ coalesce(p.work_date, r.work_date) AS work_date,
999
+ p.assignment_id,
1000
+ p.shift_label,
1001
+ p.shift_start_time,
1002
+ p.shift_end_time,
1003
+ p.assignment_status,
1004
+ (p.planned_seconds / 60)::integer AS planned_minutes,
1005
+ (r.worked_seconds / 60)::integer AS worked_minutes,
1006
+ ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,
1007
+ r.first_in,
1008
+ r.last_out,
1009
+ r.break_count,
1010
+ coalesce(r.is_open, false) AS is_open,
1011
+ -- As duas assimetrias, nomeadas. S\xE3o as perguntas do RH, e nenhuma delas
1012
+ -- tem resposta no V1.
1013
+ (r.person_id IS NULL) AS absent,
1014
+ (p.assignment_id IS NULL) AS outside_schedule
1015
+ FROM planned p
1016
+ FULL OUTER JOIN realized r
1017
+ ON r.tenant_id = p.tenant_id
1018
+ AND r.person_id = p.person_id
1019
+ AND r.work_date = p.work_date;
1020
+
1021
+ COMMENT ON VIEW public.v_workforce_day IS
1022
+ 'Jornada prevista \xD7 realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: \`absent\` \xE9 escala sem ponto, \`outside_schedule\` \xE9 ponto sem escala \u2014 as duas perguntas que o V1 n\xE3o responde.';
1023
+
1024
+ GRANT SELECT ON public.v_workforce_time_entries TO authenticated, service_role;
1025
+ GRANT SELECT ON public.v_workforce_day TO authenticated, service_role;
1026
+
1027
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1028
+ -- 8. OS WRITERS da migra\xE7\xE3o
1029
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1030
+
1031
+ -- O vocabul\xE1rio do de-para \xE9 lista fechada de prop\xF3sito: uma linha que nomeia um
1032
+ -- writer inexistente falha na hora, n\xE3o na terceira hora da onda.
1033
+ --
1034
+ -- ACR\xC9SCIMO, e n\xE3o reemiss\xE3o. A vers\xE3o anterior reescrevia a lista INTEIRA \u2014
1035
+ -- "a lista at\xE9 aqui (046) mais os oito da jornada". Uma lista escrita por
1036
+ -- extenso \xE9 um retrato: ela apaga todo writer que outro passo registrou e que
1037
+ -- o retrato n\xE3o viu. Medido no cluster vivo, re-aplicar esta baseline dava
1038
+ --
1039
+ -- 23514: check constraint "allowlist_writer_check" is violated by some row
1040
+ --
1041
+ -- porque l\xE1 j\xE1 havia writers que esta lista n\xE3o nomeia. \xC9 o mesmo defeito que
1042
+ -- \`agenda/008\` e \`financial/072\` j\xE1 pagaram, e a forma abaixo \xE9 a de
1043
+ -- \`inventory/004\`: leia o que existe, acrescente o que falta, nunca reemita.
1044
+ DO $$
1045
+ DECLARE
1046
+ v_def text;
1047
+ v_falta text[];
1048
+ v_meus text[] := ARRAY[
1049
+ 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift',
1050
+ 'wf_schedule_requirement', 'wf_schedule_template_assignment',
1051
+ 'wf_schedule', 'wf_schedule_assignment', 'wf_assignment_log'];
1052
+ BEGIN
1053
+ SELECT pg_get_constraintdef(oid) INTO v_def
1054
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
1055
+
1056
+ IF v_def IS NULL THEN
1057
+ -- Ningu\xE9m escreveu a regra ainda: aqui ela nasce, com os writers desta
1058
+ -- jornada e os que a corrente j\xE1 registrou at\xE9 este ponto.
1059
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
1060
+ CHECK (writer IS NULL OR writer = ANY (ARRAY[
1061
+ 'person', 'unit', 'category', 'product', 'order', 'invoice', 'price_table',
1062
+ 'price_table_item', 'package', 'package_item', 'field_rule', 'holiday', 'facet',
1063
+ 'fin_account', 'fin_payment_method_type', 'fin_payment_method', 'fin_card_brand',
1064
+ 'fin_chart_account', 'fin_cost_center', 'inv_measurement_unit', 'inv_unit_conversion',
1065
+ 'inv_location', 'inv_movement', 'order_item', 'fin_movement', 'authz_role',
1066
+ 'authz_role_permission', 'person_registration',
1067
+ 'fin_recurring_bill', 'fin_recurring_bill_item', 'fin_recurring_bill_occurrence',
1068
+ 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift', 'wf_schedule_requirement',
1069
+ 'wf_schedule_template_assignment', 'wf_schedule', 'wf_schedule_assignment',
1070
+ 'wf_assignment_log']));
1071
+ RETURN;
1072
+ END IF;
1073
+
1074
+ SELECT array_agg(w) INTO v_falta
1075
+ FROM unnest(v_meus) AS w
1076
+ WHERE strpos(v_def, '''' || w || '''') = 0;
1077
+
1078
+ IF v_falta IS NULL THEN RETURN; END IF;
1079
+
1080
+ -- Entram na frente do primeiro elemento: a posi\xE7\xE3o n\xE3o importa para um
1081
+ -- \`= ANY\`, e ancorar em \`ARRAY[\` dispensa conhecer qualquer valor da lista
1082
+ -- \u2014 que \xE9 justamente o que n\xE3o se pode presumir.
1083
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
1084
+ EXECUTE 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check '
1085
+ || replace(v_def, 'ARRAY[',
1086
+ 'ARRAY[' || (SELECT string_agg(quote_literal(w) || '::text, ', '')
1087
+ FROM unnest(v_falta) AS w));
1088
+ END $$;
1089
+
1090
+ -- \u2500\u2500 o de-para da pessoa polim\xF3rfica \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
1091
+ --
1092
+ -- Este helper \xE9 a tradu\xE7\xE3o de \`person_type\` + \`person_id\` para a FK \xFAnica do V2,
1093
+ -- e existe uma vez porque quatro writers precisam dela. O ledger \xE9 a \xFAnica fonte
1094
+ -- que sabe o de-para, e ele guarda a TABELA de origem \u2014 ent\xE3o 'professional' e
1095
+ -- 'employee' resolvem pelo mesmo caminho, e o writer n\xE3o precisa conhecer
1096
+ -- nenhuma das duas.
1097
+ CREATE OR REPLACE FUNCTION migration._wf_person(p_tenant uuid, p_meta jsonb)
1098
+ RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
1099
+ AS $$
1100
+ SELECT l.target_id
1101
+ FROM migration.ledger l
1102
+ WHERE l.tenant_id = p_tenant
1103
+ AND l.source_table = CASE WHEN (p_meta #>> '{v1,person_type}') = 'professional'
1104
+ THEN 'professionals' ELSE 'employees' END
1105
+ AND l.source_numeric_id = nullif(p_meta #>> '{v1,person_id}', '')::bigint
1106
+ AND l.target_table = 'public.people'
1107
+ AND l.status = 'migrated'
1108
+ LIMIT 1;
1109
+ $$;
1110
+
1111
+ COMMENT ON FUNCTION migration._wf_person(uuid, jsonb) IS
1112
+ 'person_type + person_id do V1 \u2192 o uuid de public.people. Duas tabelas de cadastro no V1, um cadastro s\xF3 no V2.';
1113
+
1114
+ -- Helper de migra\xE7\xE3o l\xEA o ledger inteiro do tenant: n\xE3o \xE9 fun\xE7\xE3o de aplica\xE7\xE3o.
1115
+ REVOKE ALL ON FUNCTION migration._wf_person(uuid, jsonb) FROM PUBLIC, anon, authenticated;
1116
+
1117
+ -- \u2500\u2500 a batida \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\u2500\u2500
1118
+ --
1119
+ -- UMA linha do V1 vira AT\xC9 QUATRO linhas do V2: a entrada, a sa\xEDda (quando
1120
+ -- existe), e o ajuste de cada uma (quando \`*_adjusted_at\` est\xE1 preenchido). \xC9 a
1121
+ -- tradu\xE7\xE3o do par-com-colunas-de-ajuste para eventos-com-ajuste-em-linha.
1122
+ --
1123
+ -- Os ids derivados s\xE3o determin\xEDsticos (md5 do id de origem + o sufixo do papel)
1124
+ -- para que reoferecer a mesma linha numa segunda passada ATUALIZE em vez de
1125
+ -- duplicar o dia de trabalho de algu\xE9m. \`target_id\` devolvido \xE9 o da ENTRADA:
1126
+ -- \xE9 a linha do V1 que ela representa.
1127
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_time_entry(
1128
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1129
+ ) RETURNS jsonb
1130
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1131
+ AS $function$
1132
+ DECLARE
1133
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1134
+ v_person uuid;
1135
+ v_unit uuid := (p_row ->> 'unit_id')::uuid;
1136
+ v_in timestamptz := (p_row ->> 'checkin_at')::timestamptz;
1137
+ v_out timestamptz := (p_row ->> 'checkout_at')::timestamptz;
1138
+ v_in_adj timestamptz := (p_row ->> 'checkin_adjusted_at')::timestamptz;
1139
+ v_out_adj timestamptz := (p_row ->> 'checkout_adjusted_at')::timestamptz;
1140
+ v_work_date date;
1141
+ v_out_id uuid;
1142
+ v_reason text := coalesce(nullif(btrim(coalesce(p_row ->> 'adjustment_reason', '')), ''),
1143
+ 'ajuste importado do V1 sem motivo registrado');
1144
+ v_evidence jsonb;
1145
+ v_created boolean := false;
1146
+ v_standalone boolean := migration._standalone();
1147
+ BEGIN
1148
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1149
+
1150
+ IF p_id IS NULL OR v_in IS NULL THEN
1151
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1152
+ 'reason', 'uma batida precisa de id e de hora de entrada');
1153
+ END IF;
1154
+
1155
+ -- Recusar \xE9 o certo, e \xE9 tempor\xE1rio: a pessoa entra na mesma onda, e a segunda
1156
+ -- passada oferece a batida de novo. Inventar a pessoa aqui seria pendurar
1157
+ -- horas de trabalho \u2014 e portanto dinheiro \u2014 em quem n\xE3o existe.
1158
+ v_person := migration._wf_person(p_tenant, v_meta);
1159
+ IF v_person IS NULL THEN
1160
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1161
+ 'reason', format('batida de %s #%s cuja pessoa ainda n\xE3o migrou',
1162
+ coalesce(v_meta #>> '{v1,person_type}', '<sem tipo>'),
1163
+ coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));
1164
+ END IF;
1165
+
1166
+ -- A jornada \xE9 do dia da ENTRADA, sempre \u2014 mesmo quando a sa\xEDda \xE9 do dia
1167
+ -- seguinte. \xC9 a regra que o V1 admite na caixa de ajuste e nunca grava.
1168
+ v_work_date := coalesce((p_row ->> 'work_date')::date, v_in::date);
1169
+ v_out_id := md5(p_id::text || ':clock_out')::uuid;
1170
+
1171
+ -- Todo o conjunto de prova do totem numa chave s\xF3. Perder qualquer um destes
1172
+ -- campos \xE9 perder a defesa de uma batida contestada.
1173
+ v_evidence := jsonb_strip_nulls(jsonb_build_object(
1174
+ 'photo_path', p_row ->> 'checkin_photo_path',
1175
+ 'lat', (p_row ->> 'checkin_lat')::numeric,
1176
+ 'lng', (p_row ->> 'checkin_lng')::numeric,
1177
+ 'accuracy_m', (p_row ->> 'checkin_accuracy_m')::numeric,
1178
+ 'ip', p_row ->> 'checkin_ip',
1179
+ 'user_agent', p_row ->> 'checkin_user_agent',
1180
+ 'face_score', (p_row ->> 'checkin_face_score')::numeric));
1181
+
1182
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = p_id) THEN
1183
+ INSERT INTO public.plg_workforce_time_entries
1184
+ (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,
1185
+ origin, evidence, assignment_id, notes, metadata)
1186
+ VALUES (p_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_in', v_in,
1187
+ v_work_date, 1, 'migration', v_evidence,
1188
+ (p_row ->> 'assignment_id')::uuid, p_row ->> 'notes', v_meta);
1189
+ v_created := true;
1190
+ ELSE
1191
+ UPDATE public.plg_workforce_time_entries SET
1192
+ unit_id = coalesce(v_unit, unit_id),
1193
+ person_id = v_person,
1194
+ person_name = coalesce(p_row ->> 'person_name', person_name),
1195
+ punched_at = v_in,
1196
+ work_date = v_work_date,
1197
+ evidence = migration._merge_meta(evidence, v_evidence),
1198
+ assignment_id = coalesce((p_row ->> 'assignment_id')::uuid, assignment_id),
1199
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
1200
+ metadata = migration._merge_meta(metadata, v_meta)
1201
+ WHERE id = p_id;
1202
+ END IF;
1203
+
1204
+ -- A sa\xEDda s\xF3 existe quando existe. \`checkout_at IS NULL\` \xE9 o "ponto em aberto"
1205
+ -- do V1 \u2014 20 deles no tenant medido \u2014 e ele atravessa como aus\xEAncia de evento,
1206
+ -- que \xE9 o que ele \xE9. Fabricar uma sa\xEDda para "fechar" o registro inventaria
1207
+ -- horas trabalhadas.
1208
+ IF v_out IS NOT NULL THEN
1209
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = v_out_id) THEN
1210
+ INSERT INTO public.plg_workforce_time_entries
1211
+ (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,
1212
+ origin, evidence, assignment_id, metadata)
1213
+ VALUES (v_out_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_out', v_out,
1214
+ v_work_date, 2, 'migration',
1215
+ jsonb_strip_nulls(jsonb_build_object(
1216
+ 'photo_path', p_row ->> 'checkout_photo_path',
1217
+ 'lat', (p_row ->> 'checkout_lat')::numeric,
1218
+ 'lng', (p_row ->> 'checkout_lng')::numeric,
1219
+ 'accuracy_m', (p_row ->> 'checkout_accuracy_m')::numeric,
1220
+ 'ip', p_row ->> 'checkout_ip',
1221
+ 'user_agent', p_row ->> 'checkout_user_agent',
1222
+ 'face_score', (p_row ->> 'checkout_face_score')::numeric)),
1223
+ (p_row ->> 'assignment_id')::uuid,
1224
+ migration._deep_set(v_meta, ARRAY['migration', 'derived_from'], to_jsonb(p_id::text)));
1225
+ ELSE
1226
+ UPDATE public.plg_workforce_time_entries SET
1227
+ punched_at = v_out, work_date = v_work_date, person_id = v_person
1228
+ WHERE id = v_out_id;
1229
+ END IF;
1230
+ END IF;
1231
+
1232
+ -- O ajuste vira LINHA. No V1 ele \xE9 um par de colunas por cima da batida, e o
1233
+ -- hist\xF3rico morre no segundo ajuste; aqui cada corre\xE7\xE3o fica, com autor e
1234
+ -- motivo. O motivo \xE9 opcional no V1 e obrigat\xF3rio aqui \u2014 quando vem vazio, o
1235
+ -- texto diz exatamente isso, em vez de fingir que algu\xE9m justificou.
1236
+ IF v_in_adj IS NOT NULL THEN
1237
+ INSERT INTO public.plg_workforce_time_adjustments
1238
+ (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,
1239
+ adjusted_by_user_id, adjusted_by_name, metadata)
1240
+ VALUES (md5(p_id::text || ':adjust_in')::uuid, p_tenant, v_unit, p_id, 'correct',
1241
+ v_in_adj, v_reason,
1242
+ (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',
1243
+ migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkin_adjusted_at'::text)))
1244
+ ON CONFLICT (id) DO UPDATE
1245
+ SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,
1246
+ adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();
1247
+ END IF;
1248
+
1249
+ IF v_out_adj IS NOT NULL AND v_out IS NOT NULL THEN
1250
+ INSERT INTO public.plg_workforce_time_adjustments
1251
+ (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,
1252
+ adjusted_by_user_id, adjusted_by_name, metadata)
1253
+ VALUES (md5(p_id::text || ':adjust_out')::uuid, p_tenant, v_unit, v_out_id, 'correct',
1254
+ v_out_adj, v_reason,
1255
+ (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',
1256
+ migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkout_adjusted_at'::text)))
1257
+ ON CONFLICT (id) DO UPDATE
1258
+ SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,
1259
+ adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();
1260
+ END IF;
1261
+
1262
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_time_entries', p_id, '{}'::jsonb); END IF;
1263
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1264
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,
1265
+ 'entries', CASE WHEN v_out IS NULL THEN 1 ELSE 2 END,
1266
+ 'open', v_out IS NULL);
1267
+ END $function$;
1268
+
1269
+ -- \u2500\u2500 o molde \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\u2500\u2500\u2500
1270
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template(
1271
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1272
+ ) RETURNS jsonb
1273
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1274
+ AS $function$
1275
+ DECLARE
1276
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1277
+ v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');
1278
+ v_created boolean := false;
1279
+ v_standalone boolean := migration._standalone();
1280
+ BEGIN
1281
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1282
+ IF p_id IS NULL OR v_name IS NULL THEN
1283
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1284
+ 'reason', 'um template de escala precisa de id e de nome \u2014 \xE9 por ele que a tela o oferece');
1285
+ END IF;
1286
+
1287
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_templates WHERE id = p_id) THEN
1288
+ INSERT INTO public.plg_workforce_schedule_templates (id, tenant_id, unit_id, name, is_active, notes, metadata)
1289
+ VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_name,
1290
+ coalesce((p_row ->> 'is_active')::boolean, true), p_row ->> 'notes', v_meta);
1291
+ v_created := true;
1292
+ ELSE
1293
+ UPDATE public.plg_workforce_schedule_templates SET
1294
+ unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,
1295
+ name = v_name,
1296
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
1297
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
1298
+ metadata = migration._merge_meta(metadata, v_meta)
1299
+ WHERE id = p_id;
1300
+ END IF;
1301
+
1302
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_templates', p_id, '{}'::jsonb); END IF;
1303
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1304
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
1305
+ END $function$;
1306
+
1307
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_shift(
1308
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1309
+ ) RETURNS jsonb
1310
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1311
+ AS $function$
1312
+ DECLARE
1313
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1314
+ v_template uuid := (p_row ->> 'template_id')::uuid;
1315
+ v_start time := (p_row ->> 'start_time')::time;
1316
+ v_end time := (p_row ->> 'end_time')::time;
1317
+ v_days smallint[];
1318
+ v_bad smallint[];
1319
+ v_unit uuid;
1320
+ v_created boolean := false;
1321
+ v_standalone boolean := migration._standalone();
1322
+ BEGIN
1323
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1324
+ IF p_id IS NULL OR v_template IS NULL OR v_start IS NULL OR v_end IS NULL THEN
1325
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1326
+ 'reason', 'um turno precisa de id, do template, de in\xEDcio e de fim');
1327
+ END IF;
1328
+ SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_templates
1329
+ WHERE id = v_template AND tenant_id = p_tenant;
1330
+ IF NOT FOUND THEN
1331
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1332
+ 'reason', format('o template %s n\xE3o existe neste tenant', v_template));
1333
+ END IF;
1334
+
1335
+ -- Sem corre\xE7\xE3o silenciosa nos dias da semana. Um 7 solto (ou um 1..7 em vez de
1336
+ -- 0..6) \xE9 um turno que nunca acontece, e um turno que nunca acontece \xE9 uma
1337
+ -- pessoa que ningu\xE9m chamou \u2014 some sem erro nenhum.
1338
+ SELECT coalesce(array_agg(x::smallint ORDER BY x), '{}'::smallint[])
1339
+ INTO v_days
1340
+ FROM jsonb_array_elements_text(coalesce(p_row -> 'weekdays', '[]'::jsonb)) AS t(x);
1341
+ SELECT coalesce(array_agg(d), '{}'::smallint[]) INTO v_bad
1342
+ FROM unnest(v_days) AS d WHERE d < 0 OR d > 6;
1343
+ IF array_length(v_bad, 1) > 0 THEN
1344
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1345
+ 'reason', format('dia da semana fora de 0..6 (domingo..s\xE1bado): %s', v_bad::text));
1346
+ END IF;
1347
+
1348
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_shifts WHERE id = p_id) THEN
1349
+ INSERT INTO public.plg_workforce_schedule_shifts
1350
+ (id, tenant_id, unit_id, template_id, label, start_time, end_time, weekdays, sort_order, metadata)
1351
+ VALUES (p_id, p_tenant, v_unit, v_template,
1352
+ coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), 'Turno'),
1353
+ v_start, v_end, v_days, coalesce((p_row ->> 'sort_order')::integer, 0), v_meta);
1354
+ v_created := true;
1355
+ ELSE
1356
+ UPDATE public.plg_workforce_schedule_shifts SET
1357
+ unit_id = v_unit,
1358
+ label = coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), label),
1359
+ start_time = v_start,
1360
+ end_time = v_end,
1361
+ weekdays = v_days,
1362
+ sort_order = CASE WHEN p_row ? 'sort_order' THEN coalesce((p_row ->> 'sort_order')::integer, sort_order) ELSE sort_order END,
1363
+ metadata = migration._merge_meta(metadata, v_meta)
1364
+ WHERE id = p_id;
1365
+ END IF;
1366
+
1367
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_shifts', p_id, '{}'::jsonb); END IF;
1368
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1369
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,
1370
+ 'crosses_midnight', v_end <= v_start);
1371
+ END $function$;
1372
+
1373
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_requirement(
1374
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1375
+ ) RETURNS jsonb
1376
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1377
+ AS $function$
1378
+ DECLARE
1379
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1380
+ v_shift uuid := (p_row ->> 'shift_id')::uuid;
1381
+ v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');
1382
+ v_count integer := coalesce((p_row ->> 'required_count')::integer, 1);
1383
+ v_unit uuid;
1384
+ v_id uuid;
1385
+ v_standalone boolean := migration._standalone();
1386
+ BEGIN
1387
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1388
+ IF p_id IS NULL OR v_shift IS NULL OR v_prof IS NULL THEN
1389
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1390
+ 'reason', 'uma vaga precisa de id, do turno e do nome da fun\xE7\xE3o');
1391
+ END IF;
1392
+ SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_shifts
1393
+ WHERE id = v_shift AND tenant_id = p_tenant;
1394
+ IF NOT FOUND THEN
1395
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1396
+ 'reason', format('o turno %s n\xE3o existe neste tenant', v_shift));
1397
+ END IF;
1398
+ -- Lota\xE7\xE3o zero ou negativa \xE9 vaga que n\xE3o existe; e a constraint recusaria a
1399
+ -- linha inteira mais tarde, sem dizer qual era.
1400
+ IF v_count <= 0 THEN
1401
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1402
+ 'reason', format('lota\xE7\xE3o de %L \xE9 %s \u2014 uma vaga precisa de pelo menos uma pessoa', v_prof, v_count));
1403
+ END IF;
1404
+
1405
+ -- Devolve o id da linha que de fato ficou: quando duas vagas do V1 nomeiam a
1406
+ -- mesma fun\xE7\xE3o no mesmo turno, a segunda funde na primeira, e o ledger tem de
1407
+ -- apontar para onde o dado foi parar \u2014 n\xE3o para o id que ela trazia.
1408
+ INSERT INTO public.plg_workforce_schedule_requirements
1409
+ (id, tenant_id, unit_id, shift_id, profession_name, required_count, metadata)
1410
+ VALUES (p_id, p_tenant, v_unit, v_shift, v_prof, v_count, v_meta)
1411
+ ON CONFLICT (shift_id, profession_name) DO UPDATE
1412
+ SET required_count = EXCLUDED.required_count,
1413
+ metadata = migration._merge_meta(public.plg_workforce_schedule_requirements.metadata, EXCLUDED.metadata),
1414
+ updated_at = now()
1415
+ RETURNING id INTO v_id;
1416
+
1417
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_requirements', v_id, '{}'::jsonb); END IF;
1418
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_id,
1419
+ 'reason', CASE WHEN v_id = p_id THEN 'upserted' ELSE 'fundida na vaga que j\xE1 existia para esta fun\xE7\xE3o neste turno' END);
1420
+ END $function$;
1421
+
1422
+ -- No molde do V1, titular vs stand-by \xE9 POSICIONAL \u2014 os primeiros
1423
+ -- \`required_count\` por \`priority\`. Este writer faz essa conta UMA vez, na
1424
+ -- entrada, e grava o resultado em \`role\`. Deixar a regra impl\xEDcita no V2 seria
1425
+ -- repor um comportamento que s\xF3 existe no c\xF3digo de uma tela, e que a pr\xF3xima
1426
+ -- tela a ler a mesma tabela erraria em sil\xEAncio.
1427
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template_assignment(
1428
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1429
+ ) RETURNS jsonb
1430
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1431
+ AS $function$
1432
+ DECLARE
1433
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1434
+ v_shift uuid := (p_row ->> 'shift_id')::uuid;
1435
+ v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');
1436
+ v_priority integer := coalesce((p_row ->> 'priority')::integer, 0);
1437
+ v_person uuid;
1438
+ v_template uuid;
1439
+ v_unit uuid;
1440
+ v_required integer;
1441
+ v_rank integer;
1442
+ v_role text;
1443
+ v_standalone boolean := migration._standalone();
1444
+ BEGIN
1445
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1446
+ IF p_id IS NULL OR v_shift IS NULL THEN
1447
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1448
+ 'reason', 'uma aloca\xE7\xE3o de molde precisa de id e do turno');
1449
+ END IF;
1450
+
1451
+ -- No molde a pessoa \xE9 sempre \`professionals\` \u2014 o V1 n\xE3o escala funcion\xE1rio
1452
+ -- pelo template \u2014 mas o helper resolve pelo que o metadata disser, e n\xE3o pelo
1453
+ -- que este writer sup\xF5e.
1454
+ v_person := migration._wf_person(p_tenant,
1455
+ CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL
1456
+ THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))
1457
+ ELSE v_meta END);
1458
+ IF v_person IS NULL THEN
1459
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1460
+ 'reason', format('aloca\xE7\xE3o de molde cuja pessoa ainda n\xE3o migrou (%s #%s)',
1461
+ coalesce(v_meta #>> '{v1,person_type}', 'professional'),
1462
+ coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));
1463
+ END IF;
1464
+
1465
+ SELECT s.template_id, s.unit_id INTO v_template, v_unit
1466
+ FROM public.plg_workforce_schedule_shifts s
1467
+ WHERE s.id = v_shift AND s.tenant_id = p_tenant;
1468
+ IF NOT FOUND THEN
1469
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1470
+ 'reason', format('o turno %s n\xE3o existe neste tenant', v_shift));
1471
+ END IF;
1472
+
1473
+ INSERT INTO public.plg_workforce_schedule_template_assignments
1474
+ (id, tenant_id, unit_id, template_id, shift_id, profession_name, person_id, role, priority, metadata)
1475
+ VALUES (p_id, p_tenant, v_unit, v_template, v_shift, coalesce(v_prof, '\u2014'), v_person,
1476
+ 'titular', v_priority, v_meta)
1477
+ ON CONFLICT (shift_id, profession_name, person_id) DO UPDATE
1478
+ SET priority = EXCLUDED.priority,
1479
+ metadata = migration._merge_meta(public.plg_workforce_schedule_template_assignments.metadata, EXCLUDED.metadata),
1480
+ updated_at = now();
1481
+
1482
+ -- A POSI\xC7\xC3O vira PAPEL. Recalculada a cada linha porque a ordem muda quando a
1483
+ -- pr\xF3xima chega: a quarta pessoa de uma vaga de tr\xEAs rebaixa ningu\xE9m, mas
1484
+ -- entra como reserva, e a s\xE9tima tamb\xE9m.
1485
+ SELECT r.required_count INTO v_required
1486
+ FROM public.plg_workforce_schedule_requirements r
1487
+ WHERE r.shift_id = v_shift AND r.profession_name = coalesce(v_prof, '\u2014');
1488
+
1489
+ SELECT rank INTO v_rank FROM (
1490
+ SELECT a.id, row_number() OVER (ORDER BY a.priority, a.created_at, a.id) AS rank
1491
+ FROM public.plg_workforce_schedule_template_assignments a
1492
+ WHERE a.shift_id = v_shift AND a.profession_name = coalesce(v_prof, '\u2014')) s
1493
+ WHERE s.id = p_id;
1494
+
1495
+ -- Sem vaga declarada n\xE3o h\xE1 denominador, e chutar um faria a pessoa virar
1496
+ -- titular ou reserva por acaso. \`titular\` \xE9 o que o V1 mostra nesse caso.
1497
+ v_role := CASE WHEN v_required IS NULL THEN 'titular'
1498
+ WHEN v_rank <= v_required THEN 'titular'
1499
+ ELSE 'standby' END;
1500
+ UPDATE public.plg_workforce_schedule_template_assignments SET role = v_role WHERE id = p_id;
1501
+
1502
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_template_assignments', p_id, '{}'::jsonb); END IF;
1503
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,
1504
+ 'reason', format('posi\xE7\xE3o %s de %s vaga(s) \u2192 %s', v_rank, coalesce(v_required::text, 'sem lota\xE7\xE3o declarada'), v_role));
1505
+ END $function$;
1506
+
1507
+ -- \u2500\u2500 a inst\xE2ncia \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
1508
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule(
1509
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1510
+ ) RETURNS jsonb
1511
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1512
+ AS $function$
1513
+ DECLARE
1514
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1515
+ v_date date := (p_row ->> 'schedule_date')::date;
1516
+ v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');
1517
+ v_status text;
1518
+ v_published timestamptz := (p_row ->> 'published_at')::timestamptz;
1519
+ v_created boolean := false;
1520
+ v_standalone boolean := migration._standalone();
1521
+ BEGIN
1522
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1523
+ IF p_id IS NULL OR v_date IS NULL THEN
1524
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1525
+ 'reason', 'uma escala precisa de id e do dia a que pertence');
1526
+ END IF;
1527
+
1528
+ v_status := CASE v_status_v1
1529
+ WHEN 'draft' THEN 'draft'
1530
+ WHEN 'published' THEN 'published'
1531
+ WHEN 'closed' THEN 'closed'
1532
+ ELSE NULL END;
1533
+ IF v_status IS NULL THEN
1534
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1535
+ 'reason', format('estado de escala desconhecido: %L (o V2 conhece draft, published e closed)', v_status_v1));
1536
+ END IF;
1537
+
1538
+ -- Publicada sem carimbo \xE9 o rascunho do V1 que algu\xE9m marcou como publicado
1539
+ -- sem a a\xE7\xE3o que grava a data. A constraint recusaria; aqui a data de
1540
+ -- publica\xE7\xE3o vira a do pr\xF3prio dia da escala, e o remendo fica escrito.
1541
+ IF v_status <> 'draft' AND v_published IS NULL THEN
1542
+ v_published := v_date::timestamptz;
1543
+ v_meta := migration._deep_set(v_meta, ARRAY['migration', 'published_at_inferred'], to_jsonb(true));
1544
+ END IF;
1545
+
1546
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedules WHERE id = p_id) THEN
1547
+ INSERT INTO public.plg_workforce_schedules
1548
+ (id, tenant_id, unit_id, schedule_date, template_id, status, published_at,
1549
+ is_special_day, special_day_label, notes, metadata)
1550
+ VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_date,
1551
+ (p_row ->> 'template_id')::uuid, v_status, v_published,
1552
+ coalesce((p_row ->> 'is_special_day')::boolean, false),
1553
+ p_row ->> 'special_day_label', p_row ->> 'notes', v_meta);
1554
+ v_created := true;
1555
+ ELSE
1556
+ UPDATE public.plg_workforce_schedules SET
1557
+ unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,
1558
+ schedule_date = v_date,
1559
+ template_id = CASE WHEN p_row ? 'template_id' THEN (p_row ->> 'template_id')::uuid ELSE template_id END,
1560
+ status = v_status,
1561
+ published_at = v_published,
1562
+ is_special_day = CASE WHEN p_row ? 'is_special_day' THEN coalesce((p_row ->> 'is_special_day')::boolean, false) ELSE is_special_day END,
1563
+ special_day_label = CASE WHEN p_row ? 'special_day_label' THEN p_row ->> 'special_day_label' ELSE special_day_label END,
1564
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
1565
+ metadata = migration._merge_meta(metadata, v_meta)
1566
+ WHERE id = p_id;
1567
+ END IF;
1568
+
1569
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedules', p_id, '{}'::jsonb); END IF;
1570
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1571
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
1572
+ END $function$;
1573
+
1574
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_assignment(
1575
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1576
+ ) RETURNS jsonb
1577
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1578
+ AS $function$
1579
+ DECLARE
1580
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1581
+ v_schedule uuid := (p_row ->> 'schedule_id')::uuid;
1582
+ v_role_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'role', ''))), '');
1583
+ v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');
1584
+ v_role text;
1585
+ v_status text;
1586
+ v_person uuid;
1587
+ v_unit uuid;
1588
+ v_start time := (p_row ->> 'shift_start_time')::time;
1589
+ v_end time := (p_row ->> 'shift_end_time')::time;
1590
+ v_created boolean := false;
1591
+ v_standalone boolean := migration._standalone();
1592
+ BEGIN
1593
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1594
+ IF p_id IS NULL OR v_schedule IS NULL THEN
1595
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1596
+ 'reason', 'uma atribui\xE7\xE3o precisa de id e da escala a que pertence');
1597
+ END IF;
1598
+ SELECT unit_id INTO v_unit FROM public.plg_workforce_schedules
1599
+ WHERE id = v_schedule AND tenant_id = p_tenant;
1600
+ IF NOT FOUND THEN
1601
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1602
+ 'reason', format('a escala %s n\xE3o existe neste tenant', v_schedule));
1603
+ END IF;
1604
+
1605
+ v_person := migration._wf_person(p_tenant,
1606
+ CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL
1607
+ THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))
1608
+ ELSE v_meta END);
1609
+ IF v_person IS NULL THEN
1610
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1611
+ 'reason', format('atribui\xE7\xE3o de escala cuja pessoa ainda n\xE3o migrou (%s #%s)',
1612
+ coalesce(v_meta #>> '{v1,person_type}', 'professional'),
1613
+ coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));
1614
+ END IF;
1615
+
1616
+ -- Stand-by N\xC3O \xE9 folga: \xE9 o substituto acion\xE1vel, e a pr\xF3pria tela do V1 diz
1617
+ -- ("Substitutos em caso de falta"). Confundir os dois na tradu\xE7\xE3o apagaria a
1618
+ -- fila de quem cobre a falta.
1619
+ v_role := CASE v_role_v1
1620
+ WHEN 'titular' THEN 'titular'
1621
+ WHEN 'standby' THEN 'standby'
1622
+ WHEN 'stand_by' THEN 'standby'
1623
+ WHEN 'stand-by' THEN 'standby'
1624
+ ELSE NULL END;
1625
+ IF v_role IS NULL THEN
1626
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1627
+ 'reason', format('papel na vaga desconhecido: %L (o V2 conhece titular e standby)', v_role_v1));
1628
+ END IF;
1629
+
1630
+ v_status := CASE v_status_v1
1631
+ WHEN 'pending' THEN 'pending'
1632
+ WHEN 'notified' THEN 'notified'
1633
+ WHEN 'confirmed' THEN 'confirmed'
1634
+ WHEN 'declined' THEN 'declined'
1635
+ WHEN 'realized' THEN 'realized'
1636
+ ELSE NULL END;
1637
+ IF v_status IS NULL THEN
1638
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
1639
+ 'reason', format('estado de atribui\xE7\xE3o desconhecido: %L', v_status_v1));
1640
+ END IF;
1641
+
1642
+ IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_assignments WHERE id = p_id) THEN
1643
+ INSERT INTO public.plg_workforce_schedule_assignments
1644
+ (id, tenant_id, unit_id, schedule_id, shift_id, shift_label, shift_start_time, shift_end_time,
1645
+ profession_name, person_id, role, priority, status, response_token, response_channel,
1646
+ notified_at, responded_at, metadata)
1647
+ VALUES (p_id, p_tenant, v_unit, v_schedule, (p_row ->> 'shift_id')::uuid,
1648
+ p_row ->> 'shift_label', v_start, v_end,
1649
+ p_row ->> 'profession_name', v_person, v_role,
1650
+ coalesce((p_row ->> 'priority')::integer, 0), v_status,
1651
+ -- O token do V1 \xE9 uma credencial viva de um canal que o V2 ainda n\xE3o
1652
+ -- opera. Ele atravessa para n\xE3o perder o rastro de quem foi chamado
1653
+ -- por link, mas quem emitir o pr\xF3ximo convite emite outro.
1654
+ p_row ->> 'response_token', p_row ->> 'response_channel',
1655
+ (p_row ->> 'notified_at')::timestamptz, (p_row ->> 'responded_at')::timestamptz,
1656
+ v_meta);
1657
+ v_created := true;
1658
+ ELSE
1659
+ UPDATE public.plg_workforce_schedule_assignments SET
1660
+ unit_id = v_unit,
1661
+ shift_id = CASE WHEN p_row ? 'shift_id' THEN (p_row ->> 'shift_id')::uuid ELSE shift_id END,
1662
+ shift_label = CASE WHEN p_row ? 'shift_label' THEN p_row ->> 'shift_label' ELSE shift_label END,
1663
+ shift_start_time = coalesce(v_start, shift_start_time),
1664
+ shift_end_time = coalesce(v_end, shift_end_time),
1665
+ profession_name = CASE WHEN p_row ? 'profession_name' THEN p_row ->> 'profession_name' ELSE profession_name END,
1666
+ person_id = v_person,
1667
+ role = v_role,
1668
+ priority = CASE WHEN p_row ? 'priority' THEN coalesce((p_row ->> 'priority')::integer, priority) ELSE priority END,
1669
+ status = v_status,
1670
+ response_token = CASE WHEN p_row ? 'response_token' THEN p_row ->> 'response_token' ELSE response_token END,
1671
+ response_channel = CASE WHEN p_row ? 'response_channel' THEN p_row ->> 'response_channel' ELSE response_channel END,
1672
+ notified_at = CASE WHEN p_row ? 'notified_at' THEN (p_row ->> 'notified_at')::timestamptz ELSE notified_at END,
1673
+ responded_at = CASE WHEN p_row ? 'responded_at' THEN (p_row ->> 'responded_at')::timestamptz ELSE responded_at END,
1674
+ metadata = migration._merge_meta(metadata, v_meta)
1675
+ WHERE id = p_id;
1676
+ END IF;
1677
+
1678
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_assignments', p_id, '{}'::jsonb); END IF;
1679
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,
1680
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
1681
+ END $function$;
1682
+
1683
+ CREATE OR REPLACE FUNCTION migration.upsert_wf_assignment_log(
1684
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
1685
+ ) RETURNS jsonb
1686
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
1687
+ AS $function$
1688
+ DECLARE
1689
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
1690
+ v_assignment uuid := (p_row ->> 'assignment_id')::uuid;
1691
+ v_to text := nullif(btrim(lower(coalesce(p_row ->> 'to_status', ''))), '');
1692
+ v_unit uuid;
1693
+ v_standalone boolean := migration._standalone();
1694
+ BEGIN
1695
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
1696
+ IF p_id IS NULL OR v_assignment IS NULL OR v_to IS NULL THEN
1697
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
1698
+ 'reason', 'uma linha de hist\xF3rico precisa de id, da atribui\xE7\xE3o e do estado para o qual foi');
1699
+ END IF;
1700
+ SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_assignments
1701
+ WHERE id = v_assignment AND tenant_id = p_tenant;
1702
+ IF NOT FOUND THEN
1703
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
1704
+ 'reason', format('a atribui\xE7\xE3o %s n\xE3o existe neste tenant', v_assignment));
1705
+ END IF;
1706
+
1707
+ -- O log \xE9 hist\xF3rico: reoferecer a mesma linha \xE9 replay, e replay n\xE3o reescreve
1708
+ -- o que j\xE1 foi registrado.
1709
+ IF EXISTS (SELECT 1 FROM public.plg_workforce_assignment_logs WHERE id = p_id) THEN
1710
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
1711
+ 'reason', 'already in the ledger (append-only)');
1712
+ END IF;
1713
+
1714
+ INSERT INTO public.plg_workforce_assignment_logs
1715
+ (id, tenant_id, unit_id, assignment_id, from_status, to_status, channel, note,
1716
+ changed_by_user_id, metadata, created_at)
1717
+ VALUES (p_id, p_tenant, v_unit, v_assignment,
1718
+ nullif(btrim(lower(coalesce(p_row ->> 'from_status', ''))), ''), v_to,
1719
+ p_row ->> 'channel', p_row ->> 'note',
1720
+ (p_row ->> 'changed_by_user_id')::uuid, v_meta,
1721
+ coalesce((p_row ->> 'created_at')::timestamptz, now()));
1722
+
1723
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_assignment_logs', p_id, '{}'::jsonb); END IF;
1724
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
1725
+ END $function$;
1726
+
1727
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1728
+ -- 9. O DE-PARA
1729
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1730
+
1731
+ -- \`_allow\` \xE9 o construtor oficial: a allowlist tem gatilho de congelamento, e um
1732
+ -- INSERT direto por cima de uma linha \`frozen\` seria recusado. S\xE3o oito
1733
+ -- argumentos \u2014 (source, target, domain, writer, writer_args, financial[],
1734
+ -- columns, notes) \u2014 e \`layer\` \xE9 fixo em 'core-A' l\xE1 dentro.
1735
+
1736
+ -- A allowlist \xE9 CONGELADA (\`trg_allowlist_frozen\`): mudan\xE7a de core-A \xE9
1737
+ -- arquivo de migration, n\xE3o escrita solta \u2014 e este \xE9 o arquivo. Sem a
1738
+ -- escotilha, a PRIMEIRA passada passa (a linha ainda n\xE3o existia) e a
1739
+ -- SEGUNDA falha: \xE9 assim que uma migration deixa de ser replay-safe sem
1740
+ -- ningu\xE9m ver. \`true\` no terceiro argumento: vale s\xF3 nesta transa\xE7\xE3o.
1741
+ SELECT set_config('migration.allowlist_unlock', 'on', true);
1742
+
1743
+ SELECT migration._allow(
1744
+ 'time_clock_entries', 'public.plg_workforce_time_entries', 'workforce',
1745
+ 'wf_time_entry', '{}'::jsonb, ARRAY[]::text[],
1746
+ jsonb_build_object(
1747
+ 'uuid', 'id',
1748
+ 'unit_id', 'unit_id@companies:app.units',
1749
+ -- O par polim\xF3rfico. Nenhuma das duas vira coluna: juntas elas viram
1750
+ -- \`person_id\`, resolvido pelo ledger em migration._wf_person.
1751
+ 'person_type', 'jsonb:v1.person_type',
1752
+ 'person_id', 'jsonb:v1.person_id',
1753
+ 'person_name', 'person_name',
1754
+ 'checkin_at', 'checkin_at',
1755
+ 'checkout_at', 'checkout_at',
1756
+ 'checkin_photo_path', 'checkin_photo_path',
1757
+ 'checkin_lat', 'checkin_lat',
1758
+ 'checkin_lng', 'checkin_lng',
1759
+ 'checkin_accuracy_m', 'checkin_accuracy_m',
1760
+ 'checkin_ip', 'checkin_ip',
1761
+ 'checkin_user_agent', 'checkin_user_agent',
1762
+ 'checkin_face_score', 'checkin_face_score',
1763
+ 'checkout_photo_path', 'checkout_photo_path',
1764
+ 'checkout_lat', 'checkout_lat',
1765
+ 'checkout_lng', 'checkout_lng',
1766
+ 'checkout_accuracy_m', 'checkout_accuracy_m',
1767
+ 'checkout_ip', 'checkout_ip',
1768
+ 'checkout_user_agent', 'checkout_user_agent',
1769
+ 'checkout_face_score', 'checkout_face_score',
1770
+ 'schedule_assignment_id','assignment_id@schedule_assignments',
1771
+ -- O ajuste do V1 n\xE3o vira coluna: vira LINHA em
1772
+ -- plg_workforce_time_adjustments, com autor e motivo.
1773
+ 'checkin_adjusted_at', 'checkin_adjusted_at',
1774
+ 'checkout_adjusted_at', 'checkout_adjusted_at',
1775
+ 'adjustment_reason', 'adjustment_reason',
1776
+ 'adjusted_by_user_id', 'adjusted_by_user_id',
1777
+ 'adjusted_by_name', 'adjusted_by_name',
1778
+ -- Derivadas e n\xE3o portadas: \`manually_adjusted\` \xE9 a exist\xEAncia de um ajuste,
1779
+ -- \`adjusted_by\`/\`adjusted_at\` s\xE3o redundantes com a linha do ajuste, e
1780
+ -- \`outside_schedule\` \xE9 o resultado do cruzamento \u2014 que no V1 \xE9 \`true\` em 325
1781
+ -- de 325 registros por um defeito de configura\xE7\xE3o, e que no V2 \xE9 a coluna
1782
+ -- \`outside_schedule\` da view v_workforce_day, calculada e n\xE3o guardada.
1783
+ 'manually_adjusted', '-',
1784
+ 'adjusted_by', '-',
1785
+ 'adjusted_at', '-',
1786
+ 'outside_schedule', 'jsonb:v1.outside_schedule',
1787
+ 'notes', 'notes',
1788
+ -- N\xC3O vira FK: no V1 aponta para a tabela de t\xEDtulos do V1, cuja contraparte
1789
+ -- no V2 n\xE3o \xE9 a mesma coisa (mesma recusa da 046). A refer\xEAncia crua \xE9 o que
1790
+ -- TRAVA o ajuste da linha migrada \u2014 a trigger da se\xE7\xE3o 4 l\xEA exatamente aqui.
1791
+ 'payable_invoice_id', 'jsonb:v1.payable_invoice_id'
1792
+ ),
1793
+ 'A batida. Uma linha do V1 (par entrada/sa\xEDda + colunas de ajuste) vira at\xE9 4 do V2: dois eventos append-only e duas linhas de ajuste. checkout_at nulo atravessa como aus\xEAncia de evento \u2014 \xE9 o ponto em aberto, 20 deles no tenant medido.'
1794
+ );
1795
+
1796
+ SELECT migration._allow(
1797
+ 'schedule_templates', 'public.plg_workforce_schedule_templates', 'workforce',
1798
+ 'wf_schedule_template', '{}'::jsonb, ARRAY[]::text[],
1799
+ jsonb_build_object(
1800
+ 'uuid', 'id',
1801
+ 'unit_id', 'unit_id@companies:app.units',
1802
+ 'name', 'name',
1803
+ 'active', 'is_active'
1804
+ ),
1805
+ 'O molde da escala. Pr\xE9-requisito duro: sem template o V1 n\xE3o deixa criar escala nenhuma.'
1806
+ );
1807
+
1808
+ SELECT migration._allow(
1809
+ 'schedule_template_shifts', 'public.plg_workforce_schedule_shifts', 'workforce',
1810
+ 'wf_schedule_shift', '{}'::jsonb, ARRAY[]::text[],
1811
+ jsonb_build_object(
1812
+ 'uuid', 'id',
1813
+ 'template_id', 'template_id@schedule_templates',
1814
+ 'label', 'label',
1815
+ 'start_time', 'start_time',
1816
+ 'end_time', 'end_time',
1817
+ 'weekdays', 'weekdays',
1818
+ 'sort_order', 'sort_order'
1819
+ ),
1820
+ 'O turno do molde. weekdays \xE9 0..6 (domingo..s\xE1bado); qualquer valor fora disso \xE9 quarentena, porque um turno que nunca acontece some sem erro.'
1821
+ );
1822
+
1823
+ SELECT migration._allow(
1824
+ 'schedule_template_requirements', 'public.plg_workforce_schedule_requirements', 'workforce',
1825
+ 'wf_schedule_requirement', '{}'::jsonb, ARRAY[]::text[],
1826
+ jsonb_build_object(
1827
+ 'uuid', 'id',
1828
+ 'shift_id', 'shift_id@schedule_template_shifts',
1829
+ 'profession_name', 'profession_name',
1830
+ 'required_count', 'required_count'
1831
+ ),
1832
+ 'A lota\xE7\xE3o da fun\xE7\xE3o no turno \u2014 o "(1/1)" da tela, e o denominador que transforma posi\xE7\xE3o em titular/stand-by.'
1833
+ );
1834
+
1835
+ SELECT migration._allow(
1836
+ 'schedule_template_assignments', 'public.plg_workforce_schedule_template_assignments', 'workforce',
1837
+ 'wf_schedule_template_assignment', '{}'::jsonb, ARRAY[]::text[],
1838
+ jsonb_build_object(
1839
+ 'uuid', 'id',
1840
+ 'template_id', 'template_id@schedule_templates',
1841
+ 'shift_id', 'shift_id@schedule_template_shifts',
1842
+ 'profession_name', 'profession_name',
1843
+ 'professional_id', 'jsonb:v1.person_id',
1844
+ 'priority', 'priority'
1845
+ ),
1846
+ 'A aloca\xE7\xE3o do molde. No V1 titular \xD7 stand-by \xE9 POSICIONAL (os primeiros required_count por priority) e vive s\xF3 no c\xF3digo da tela; o writer faz a conta uma vez e grava em \`role\`.'
1847
+ );
1848
+
1849
+ SELECT migration._allow(
1850
+ 'schedules', 'public.plg_workforce_schedules', 'workforce',
1851
+ 'wf_schedule', '{}'::jsonb, ARRAY[]::text[],
1852
+ jsonb_build_object(
1853
+ 'uuid', 'id',
1854
+ 'unit_id', 'unit_id@companies:app.units',
1855
+ 'schedule_date', 'schedule_date',
1856
+ 'template_id', 'template_id@schedule_templates',
1857
+ 'status', 'status',
1858
+ 'published_at', 'published_at',
1859
+ 'is_special_day', 'is_special_day',
1860
+ 'special_day_label', 'special_day_label',
1861
+ 'notes', 'notes'
1862
+ ),
1863
+ 'A escala do dia. Hom\xF4nima da \`public.schedules\` do V2, que \xE9 OUTRA coisa (a oferta de hor\xE1rio do spine) \u2014 \xE9 a raz\xE3o do prefixo plg_workforce_.'
1864
+ );
1865
+
1866
+ SELECT migration._allow(
1867
+ 'schedule_assignments', 'public.plg_workforce_schedule_assignments', 'workforce',
1868
+ 'wf_schedule_assignment', '{}'::jsonb, ARRAY[]::text[],
1869
+ jsonb_build_object(
1870
+ 'uuid', 'id',
1871
+ 'schedule_id', 'schedule_id@schedules',
1872
+ 'shift_id', 'shift_id@schedule_template_shifts',
1873
+ -- O SNAPSHOT do V1 atravessa como snapshot. Reconstru\xED-lo a partir do turno
1874
+ -- atual do molde reescreveria a escala de anteontem quando o molde mudasse.
1875
+ 'shift_label', 'shift_label',
1876
+ 'shift_start_time', 'shift_start_time',
1877
+ 'shift_end_time', 'shift_end_time',
1878
+ 'profession_name', 'profession_name',
1879
+ 'professional_id', 'jsonb:v1.person_id',
1880
+ 'role', 'role',
1881
+ 'priority', 'priority',
1882
+ 'status', 'status',
1883
+ 'response_token', 'response_token',
1884
+ 'response_channel', 'response_channel',
1885
+ 'notified_at', 'notified_at',
1886
+ 'responded_at', 'responded_at'
1887
+ ),
1888
+ 'A atribui\xE7\xE3o do dia. Stand-by n\xE3o \xE9 folga: \xE9 o substituto acion\xE1vel ("Substitutos em caso de falta"), e confundir os dois apagaria a fila de quem cobre a falta.'
1889
+ );
1890
+
1891
+ SELECT migration._allow(
1892
+ 'schedule_assignment_logs', 'public.plg_workforce_assignment_logs', 'workforce',
1893
+ 'wf_assignment_log', '{}'::jsonb, ARRAY[]::text[],
1894
+ jsonb_build_object(
1895
+ 'uuid', 'id',
1896
+ 'assignment_id', 'assignment_id@schedule_assignments',
1897
+ 'from_status', 'from_status',
1898
+ 'to_status', 'to_status',
1899
+ 'channel', 'channel',
1900
+ 'note', 'note',
1901
+ 'changed_by_user_id','changed_by_user_id',
1902
+ 'created_at', 'created_at'
1903
+ ),
1904
+ 'O hist\xF3rico da atribui\xE7\xE3o: a \xFAnica parte do modelo de escala do V1 que j\xE1 nasceu append-only, e que atravessa como \xE9.'
1905
+ );
1906
+
1907
+ -- As duas que N\xC3O atravessam, e a raz\xE3o escrita onde a pr\xF3xima onda vai
1908
+ -- procurar. "Grid" aqui \xE9 grade de disponibilidade de AGENDAMENTO \u2014 quando um
1909
+ -- hor\xE1rio pode ser marcado \u2014, n\xE3o escala de trabalho. Port\xE1-las para dentro do
1910
+ -- RH criaria a terceira tabela de hor\xE1rio do cluster e colocaria a agenda dentro
1911
+ -- do plugin que o D3 mandou justamente separar dela.
1912
+ -- \`WHERE tenant_id IS NULL\` no \xE1rbitro: a 073 da espinha trocou a PK de
1913
+ -- \`migration.excluded_tables\` por dois \xEDndices \xFAnicos PARCIAIS \u2014 um global e
1914
+ -- um por inquilino \u2014, porque "n\xE3o trazer esta tabela" passou a ser decis\xE3o de
1915
+ -- cliente e n\xE3o do cluster. \xCDndice parcial s\xF3 serve de \xE1rbitro de ON CONFLICT
1916
+ -- quando o comando repete o mesmo recorte. Esta decis\xE3o \xE9 global.
1917
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
1918
+ ('schedule_grids',
1919
+ 'Apesar do nome, \xE9 grade de disponibilidade de AGENDAMENTO (quando um hor\xE1rio pode ser marcado), n\xE3o escala de trabalho. O destino natural \xE9 a agenda (public.schedules, kind working_hours), n\xE3o o RH.'),
1920
+ ('schedule_grid_blocks',
1921
+ 'Bloco da grade de agendamento \u2014 mesma raz\xE3o de schedule_grids. Escala de trabalho \xE9 plg_workforce_schedule_*.')
1922
+ ON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;
1923
+
1924
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1925
+ -- 10. A amarra\xE7\xE3o com o financeiro, quando o financeiro existe
1926
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1927
+ --
1928
+ -- Hora de trabalho vira dinheiro, e o V2 j\xE1 tem onde: \`plg_financial_invoices\`
1929
+ -- para o t\xEDtulo, \`plg_financial_chart_of_accounts\` e \`plg_financial_cost_centers\`
1930
+ -- para a classifica\xE7\xE3o. O que a jornada N\xC3O pode fazer \xE9 exigir o financeiro:
1931
+ -- o plugin \xE9 instal\xE1vel sozinho, e uma FK inline recusaria o arquivo inteiro num
1932
+ -- cluster que n\xE3o ligou o m\xF3dulo. Declarar a FK aqui, condicionada ao destino,
1933
+ -- \xE9 o que d\xE1 integridade onde ela \xE9 poss\xEDvel sem transformar prefer\xEAncia em
1934
+ -- depend\xEAncia.
1935
+ --
1936
+ -- Todas ON DELETE SET NULL: apagar um centro de custo n\xE3o pode apagar a batida
1937
+ -- de ningu\xE9m. O v\xEDnculo some, a hora fica.
1938
+ DO $$
1939
+ DECLARE
1940
+ r record;
1941
+ BEGIN
1942
+ FOR r IN
1943
+ SELECT * FROM (VALUES
1944
+ ('plg_workforce_time_entries', 'chart_account_id', 'plg_financial_chart_of_accounts'),
1945
+ ('plg_workforce_time_entries', 'cost_center_id', 'plg_financial_cost_centers'),
1946
+ ('plg_workforce_time_entries', 'payable_invoice_id', 'plg_financial_invoices'),
1947
+ ('plg_workforce_schedule_shifts', 'chart_account_id', 'plg_financial_chart_of_accounts'),
1948
+ ('plg_workforce_schedule_shifts', 'cost_center_id', 'plg_financial_cost_centers'),
1949
+ ('plg_workforce_periods', 'chart_account_id', 'plg_financial_chart_of_accounts'),
1950
+ ('plg_workforce_periods', 'cost_center_id', 'plg_financial_cost_centers'),
1951
+ ('plg_workforce_periods', 'payable_invoice_id', 'plg_financial_invoices')
1952
+ ) AS t(tbl, col, target)
1953
+ LOOP
1954
+ CONTINUE WHEN to_regclass('public.' || r.target) IS NULL;
1955
+ CONTINUE WHEN EXISTS (SELECT 1 FROM pg_constraint
1956
+ WHERE conname = r.tbl || '_' || r.col || '_fkey'
1957
+ AND conrelid = ('public.' || r.tbl)::regclass);
1958
+ EXECUTE format(
1959
+ 'ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES public.%I(id) ON DELETE SET NULL',
1960
+ r.tbl, r.tbl || '_' || r.col || '_fkey', r.col, r.target);
1961
+ END LOOP;
1962
+ END $$;
1963
+
1964
+ SELECT set_config('migration.allowlist_unlock', 'off', true);
1965
+ `;
1966
+ var MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES = `-- ---------------------------------------------------------------------------
1967
+ -- 001_a_prova_da_batida_tem_bytes.sql \u2014 a foto do ponto atravessa, e n\xE3o s\xF3 o
1968
+ -- caminho dela.
1969
+ --
1970
+ -- A 000 escreveu, no coment\xE1rio de \`evidence\`:
1971
+ --
1972
+ -- "O que n\xE3o pode \xE9 perder: no V1 s\xE3o \`photo_path\`, \`lat\`, \`lng\`,
1973
+ -- \`accuracy_m\`, \`ip\`, \`user_agent\` e \`face_score\`."
1974
+ --
1975
+ -- Sete campos. Seis atravessaram. O s\xE9timo \u2014 o \xFAnico que uma pessoa reconhece \u2014
1976
+ -- atravessou como TEXTO: \`8/2026-07-05/professional-15-1783278419303.jpg\`,
1977
+ -- que \xE9 um objeto num bucket PRIVADO de outro projeto Supabase, o do V1.
1978
+ --
1979
+ -- Medido em 18/09/2026 no tenant Pertinho do C\xE9u: 1.320 das 1.361 batidas t\xEAm
1980
+ -- esse caminho preenchido, e nenhuma das 1.320 imagens \xE9 alcan\xE7\xE1vel daqui. No
1981
+ -- dia em que o V1 sair do ar elas deixam de existir em qualquer lugar.
1982
+ --
1983
+ -- \xC9 pior que uma coluna vazia, porque parece cheia. \`evidence.photo_path\` diz
1984
+ -- que houve captura e sugere que a captura est\xE1 guardada. A tela de Registros de
1985
+ -- Ponto, por isso mesmo, escondia a se\xE7\xE3o de evid\xEAncia inteira \u2014 o coment\xE1rio
1986
+ -- dela dizia que a evid\xEAncia estava "hoje vazia", o que j\xE1 n\xE3o era verdade.
1987
+ --
1988
+ -- Este arquivo declara para o motor de arquivos da 054 (\`migration.asset_kinds\`)
1989
+ -- que a foto de batida \xE9 uma esp\xE9cie com bytes para transportar: de que bucket
1990
+ -- ela sai, para qual vai, o que falta atravessar, e quem reescreve o ponteiro.
1991
+ -- Mover os bytes \xE9 do fayz-etl (\`fayz_etl/blobs.py\`); a regra \xE9 daqui.
1992
+ --
1993
+ -- Idempotente e replay-safe.
1994
+ -- ---------------------------------------------------------------------------
1995
+
1996
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1997
+ -- \xA71 O bucket, PRIVADO
1998
+ --
1999
+ -- N\xE3o \xE9 escolha de estilo: \xE9 biometria facial de um funcion\xE1rio, com hora e
2000
+ -- coordenada ao lado. O V1 j\xE1 a guarda em bucket privado e serve por URL
2001
+ -- assinada de curta dura\xE7\xE3o (edge function \`time-clock-photo-url\`), e o V2 n\xE3o
2002
+ -- pode rebaixar isso \u2014 um bucket p\xFAblico transformaria "a cara e o hor\xE1rio de
2003
+ -- quem trabalha aqui" em algo que basta um caminho para ver.
2004
+ --
2005
+ -- A leitura \xE9 por pasta de tenant, o mesmo escopo que o resto da plataforma usa.
2006
+ -- Com ele, \`createSignedUrl\` do lado do app funciona para quem \xE9 do tenant e
2007
+ -- falha para quem n\xE3o \xE9, sem RPC no meio.
2008
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2009
+
2010
+ INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
2011
+ VALUES ('workforce-punch-photos', 'workforce-punch-photos', false, 5242880,
2012
+ ARRAY['image/jpeg', 'image/png', 'image/webp'])
2013
+ ON CONFLICT (id) DO UPDATE SET
2014
+ public = EXCLUDED.public,
2015
+ file_size_limit = EXCLUDED.file_size_limit,
2016
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
2017
+
2018
+ DROP POLICY IF EXISTS plg_workforce_punch_photos_read ON storage.objects;
2019
+ DROP POLICY IF EXISTS plg_workforce_punch_photos_insert ON storage.objects;
2020
+ DROP POLICY IF EXISTS plg_workforce_punch_photos_update ON storage.objects;
2021
+ DROP POLICY IF EXISTS plg_workforce_punch_photos_delete ON storage.objects;
2022
+
2023
+ -- Sem \`anon\`. A prova de uma jornada n\xE3o se l\xEA deslogado.
2024
+ CREATE POLICY plg_workforce_punch_photos_read ON storage.objects
2025
+ FOR SELECT TO authenticated
2026
+ USING (
2027
+ bucket_id = 'workforce-punch-photos'
2028
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2029
+ );
2030
+
2031
+ CREATE POLICY plg_workforce_punch_photos_insert ON storage.objects
2032
+ FOR INSERT TO authenticated
2033
+ WITH CHECK (
2034
+ bucket_id = 'workforce-punch-photos'
2035
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2036
+ );
2037
+
2038
+ CREATE POLICY plg_workforce_punch_photos_update ON storage.objects
2039
+ FOR UPDATE TO authenticated
2040
+ USING (
2041
+ bucket_id = 'workforce-punch-photos'
2042
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2043
+ )
2044
+ WITH CHECK (
2045
+ bucket_id = 'workforce-punch-photos'
2046
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2047
+ );
2048
+
2049
+ -- DELETE fica de fora de prop\xF3sito: apagar a foto de uma batida \xE9 apagar prova.
2050
+ -- Quem precisar (pedido de exclus\xE3o LGPD) passa pelo \`service_role\`, que \xE9 um
2051
+ -- ato deliberado e registrado, n\xE3o um clique.
2052
+
2053
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2054
+ -- \xA72 A esp\xE9cie
2055
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2056
+
2057
+ SELECT public.register_kind('document', 'punch_photo', 'workforce', ARRAY['active'], NULL,
2058
+ 'A foto que o totem capturou na batida \u2014 prova de quem bateu o ponto, junto com GPS e score facial');
2059
+
2060
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2061
+ -- \xA73 O que falta, e o ponteiro
2062
+ --
2063
+ -- Todo o resto deste arquivo depende da 054 do base. Num pool que ainda n\xE3o a
2064
+ -- aplicou, o plugin instala sem o transporte de bytes em vez de recusar a
2065
+ -- instala\xE7\xE3o: o ponto funciona sem foto, e a foto sem o ponto n\xE3o \xE9 nada.
2066
+ -- \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2067
+
2068
+ DO $do$
2069
+ BEGIN
2070
+ IF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN
2071
+ RAISE NOTICE 'plugin-workforce 001: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';
2072
+ RETURN;
2073
+ END IF;
2074
+
2075
+ CREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS
2076
+ SELECT e.tenant_id,
2077
+ 'time_entry'::text AS subject_type,
2078
+ e.id AS subject_id,
2079
+ e.person_id,
2080
+ e.evidence ->> 'photo_path' AS source_path,
2081
+ -- O bucket do V1 \xE9 privado: n\xE3o h\xE1 URL para baixar sem chave, e o
2082
+ -- transporte usa a chave de servi\xE7o da origem.
2083
+ NULL::text AS source_url,
2084
+ -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no
2085
+ -- nome e n\xE3o \xE9 reconstru\xEDvel a partir do destino; o do V2 \xE9 deriv\xE1vel de
2086
+ -- qualquer lado, o que \xE9 o que faz o rerun sobrescrever em vez de dobrar.
2087
+ e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,
2088
+ coalesce(e.person_name, 'sem nome') || ' \xB7 '
2089
+ || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title
2090
+ FROM public.plg_workforce_time_entries e
2091
+ -- \`photo_path\` \xE9 a proced\xEAncia (o caminho no V1, que fica); \`photo\` \xE9 o
2092
+ -- ponteiro do V2. Ter o primeiro e n\xE3o o segundo \xC9 a defini\xE7\xE3o de "falta
2093
+ -- atravessar", e escrever o segundo tira a linha desta lista.
2094
+ WHERE e.evidence ? 'photo_path'
2095
+ AND NOT (e.evidence ? 'photo');
2096
+
2097
+ COMMENT ON VIEW migration.v_asset_backlog_punch_photo IS
2098
+ 'Batidas cuja foto ainda s\xF3 existe no bucket privado do V1 (001). A condi\xE7\xE3o \xC9 o ponteiro: escrever evidence.photo apaga a linha desta lista.';
2099
+
2100
+ CREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)
2101
+ RETURNS void
2102
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
2103
+ AS $fn$
2104
+ DECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;
2105
+ BEGIN
2106
+ IF v_entry IS NULL THEN
2107
+ RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';
2108
+ END IF;
2109
+ -- UPDATE numa tabela append-only: passa porque \`attach_asset\` liga
2110
+ -- \`migration.writer\`, que \xE9 a mesma escotilha que o writer da batida usa.
2111
+ -- \`photo_path\` N\xC3O \xE9 tocado \u2014 ele \xE9 de onde a imagem veio, e continua sendo.
2112
+ UPDATE public.plg_workforce_time_entries
2113
+ SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],
2114
+ jsonb_build_object(
2115
+ 'bucket', p_row ->> 'bucket',
2116
+ 'path', p_row ->> 'target_path',
2117
+ 'document_id', p_document)),
2118
+ updated_at = now()
2119
+ WHERE id = v_entry AND tenant_id = p_tenant;
2120
+ END $fn$;
2121
+
2122
+ PERFORM migration.register_asset_kind(
2123
+ 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',
2124
+ 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',
2125
+ 'A foto do totem. Bucket privado nas duas pontas: \xE9 biometria facial com hora e coordenada ao lado, e o V1 j\xE1 a tratava assim.');
2126
+ END $do$;
2127
+ `;
2128
+ var MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO = `-- ---------------------------------------------------------------------------
2129
+ -- 002_o_guarda_da_foto_olhava_o_nome_errado.sql \u2014 o \xA73 da 001 nunca rodou.
2130
+ --
2131
+ -- A 001 protege o pr\xF3prio corpo com um guard, porque tudo nela depois do \xA72
2132
+ -- depende da 054 do base:
2133
+ --
2134
+ -- IF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)')
2135
+ -- IS NULL THEN RETURN; END IF;
2136
+ --
2137
+ -- **\`to_regproc\` recebe um NOME, n\xE3o uma assinatura.** Com a lista de tipos
2138
+ -- entre par\xEAnteses ela devolve NULL \u2014 e n\xE3o levanta erro, que \xE9 exatamente o que
2139
+ -- torna isso invis\xEDvel: \`to_regproc\` foi feita para responder "n\xE3o existe" em vez
2140
+ -- de estourar. Quem l\xEA a linha l\xEA uma checagem; o Postgres l\xEA uma pergunta
2141
+ -- malformada e responde "n\xE3o existe" \xE0s duas.
2142
+ --
2143
+ -- Resultado medido no cluster em 19/09/2026, logo depois de aplicar a 001: o
2144
+ -- bucket \`workforce-punch-photos\` existe (\xA71, fora do bloco), a esp\xE9cie
2145
+ -- \`punch_photo\` est\xE1 registrada (\xA72, fora do bloco), e do \xA73 n\xE3o existe NADA \u2014
2146
+ -- nem a view de backlog, nem \`_point_punch_photo\`, nem a linha em
2147
+ -- \`migration.asset_kinds\`. A migration "passou". A foto continuou sem
2148
+ -- transporte, e o \`mig-assets\` teria relatado s\xF3 os 63 avatares como se fosse o
2149
+ -- backlog inteiro.
2150
+ --
2151
+ -- Quem quer assinatura \xE9 \`to_regprocedure\`. \xC9 a \xFAnica diferen\xE7a deste arquivo.
2152
+ --
2153
+ -- \u2500\u2500 Por que um arquivo novo, e n\xE3o uma corre\xE7\xE3o na 001 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2154
+ --
2155
+ -- A 001 j\xE1 est\xE1 no ledger deste cluster. \`fayz db apply\` trata checksum
2156
+ -- diferente num arquivo aplicado como PARADA \u2014 e est\xE1 certo: um app que j\xE1 rodou
2157
+ -- a 001 n\xE3o pode descobrir que os bytes dela mudaram. Corrigir para frente custa
2158
+ -- este arquivo; editar a 001 custaria o apply de todo app que j\xE1 a tomou.
2159
+ --
2160
+ -- Idempotente e replay-safe.
2161
+ -- ---------------------------------------------------------------------------
2162
+
2163
+ DO $do$
2164
+ BEGIN
2165
+ -- \`to_regprocedure\`, que \xE9 quem entende \`(text,text,\u2026)\`. A mesma armadilha est\xE1
2166
+ -- na 152 do base (\`to_regproc('app.scaffold_table(text,text,text,boolean,text)')\`),
2167
+ -- e \xE9 por isso que a checagem aqui \xE9 pelo NOME quando basta o nome: um guard que
2168
+ -- mente \xE9 pior que nenhum guard, porque o arquivo sai dizendo que aplicou.
2169
+ IF to_regprocedure('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN
2170
+ RAISE NOTICE 'plugin-workforce 002: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';
2171
+ RETURN;
2172
+ END IF;
2173
+
2174
+ CREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS
2175
+ SELECT e.tenant_id,
2176
+ 'time_entry'::text AS subject_type,
2177
+ e.id AS subject_id,
2178
+ e.person_id,
2179
+ e.evidence ->> 'photo_path' AS source_path,
2180
+ -- O bucket do V1 \xE9 privado: n\xE3o h\xE1 URL para baixar sem chave, e o
2181
+ -- transporte usa a chave de servi\xE7o da origem.
2182
+ NULL::text AS source_url,
2183
+ -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no
2184
+ -- nome e n\xE3o \xE9 reconstru\xEDvel a partir do destino; o do V2 \xE9 deriv\xE1vel de
2185
+ -- qualquer lado, o que \xE9 o que faz o rerun sobrescrever em vez de dobrar.
2186
+ e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,
2187
+ coalesce(e.person_name, 'sem nome') || ' \xB7 '
2188
+ || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title
2189
+ FROM public.plg_workforce_time_entries e
2190
+ -- \`photo_path\` \xE9 a proced\xEAncia (o caminho no V1, que fica); \`photo\` \xE9 o
2191
+ -- ponteiro do V2. Ter o primeiro e n\xE3o o segundo \xC9 a defini\xE7\xE3o de "falta
2192
+ -- atravessar", e escrever o segundo tira a linha desta lista.
2193
+ WHERE e.evidence ? 'photo_path'
2194
+ AND NOT (e.evidence ? 'photo');
2195
+
2196
+ COMMENT ON VIEW migration.v_asset_backlog_punch_photo IS
2197
+ 'Batidas cuja foto ainda s\xF3 existe no bucket privado do V1 (001/002). A condi\xE7\xE3o \xC9 o ponteiro: escrever evidence.photo apaga a linha desta lista.';
2198
+
2199
+ CREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)
2200
+ RETURNS void
2201
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
2202
+ AS $fn$
2203
+ DECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;
2204
+ BEGIN
2205
+ IF v_entry IS NULL THEN
2206
+ RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';
2207
+ END IF;
2208
+ -- UPDATE numa tabela append-only: passa porque \`attach_asset\` liga
2209
+ -- \`migration.writer\`, que \xE9 a mesma escotilha que o writer da batida usa.
2210
+ -- \`photo_path\` N\xC3O \xE9 tocado \u2014 ele \xE9 de onde a imagem veio, e continua sendo.
2211
+ UPDATE public.plg_workforce_time_entries
2212
+ SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],
2213
+ jsonb_build_object(
2214
+ 'bucket', p_row ->> 'bucket',
2215
+ 'path', p_row ->> 'target_path',
2216
+ 'document_id', p_document)),
2217
+ updated_at = now()
2218
+ WHERE id = v_entry AND tenant_id = p_tenant;
2219
+ END $fn$;
2220
+
2221
+ PERFORM migration.register_asset_kind(
2222
+ 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',
2223
+ 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',
2224
+ 'A foto do totem. Bucket privado nas duas pontas: \xE9 biometria facial com hora e coordenada ao lado, e o V1 j\xE1 a tratava assim.');
2225
+ END $do$;
2226
+ `;
2227
+ var MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO = `-- ---------------------------------------------------------------------------
2228
+ -- 003_o_ponto_em_aberto_para_de_mentir_a_duracao.sql \u2014 \`-497155h02\`.
2229
+ --
2230
+ -- A tela de Registros de Ponto mostra isso na coluna Dura\xE7\xE3o de todo ponto em
2231
+ -- aberto. Medido em 19/09/2026 no tenant Pertinho do C\xE9u: **19 dias negativos,
2232
+ -- e os 19 est\xE3o abertos** \u2014 nenhum dia fechado d\xE1 negativo. O n\xFAmero n\xE3o \xE9
2233
+ -- aleat\xF3rio e n\xE3o \xE9 overflow.
2234
+ --
2235
+ -- \u2500\u2500 De onde ele sai \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
2236
+ --
2237
+ -- A 000 soma as horas com sinal, e o desenho \xE9 bom: \`clock_out\` e \`break_start\`
2238
+ -- FECHAM um trecho e entram positivos, \`clock_in\` e \`break_end\` ABREM e entram
2239
+ -- negativos. Quantos intervalos o dia tiver, a conta \xE9 a mesma \u2014 \xE9 isso que o
2240
+ -- par \xFAnico do V1 n\xE3o consegue fazer.
2241
+ --
2242
+ -- Ela s\xF3 assume uma coisa que o dia em aberto n\xE3o cumpre: que toda abertura tem
2243
+ -- o seu fechamento. Um dia com uma batida s\xF3 \u2014
2244
+ --
2245
+ -- clock_in 18:09
2246
+ --
2247
+ -- \u2014 soma \`-extract(epoch FROM '2026-09-18 18:09')\`, que \xE9 \u22121.789.790. \u2060 segundos
2248
+ -- desde 1970. Dividido por 60 vira \u221229.829.302 minutos, e o formatador de horas
2249
+ -- faz o resto: \`-497155h02\`. O epoch vazou para a tela.
2250
+ --
2251
+ -- \u2500\u2500 O conserto da conta \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
2252
+ --
2253
+ -- Devolver \xE0 soma a abertura que ficou sem par. O que sobra \xE9 exatamente a
2254
+ -- soma dos trechos FECHADOS, que \xE9 a \xFAnica coisa que se pode afirmar de um dia
2255
+ -- que ainda n\xE3o terminou:
2256
+ --
2257
+ -- entrada 09:00, sa\xEDda 12:00, entrada 13:00 \u2192 3h00 fechadas, aberto desde 13:00
2258
+ -- entrada 18:09 \u2192 0h00 fechadas, aberto desde 18:09
2259
+ --
2260
+ -- Zero n\xE3o \xE9 um defeito aqui: \xE9 a resposta certa para "quanto j\xE1 fechou?" antes
2261
+ -- de qualquer fechamento. O que faltava para a tela era a OUTRA metade da
2262
+ -- pergunta, e \xE9 a coluna nova.
2263
+ --
2264
+ -- \u2500\u2500 \`open_since\`, e por que uma coluna e n\xE3o uma conta na tela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2265
+ --
2266
+ -- Quem confere ponto quer saber h\xE1 quanto tempo a pessoa est\xE1 dentro. A tela
2267
+ -- n\xE3o consegue descobrir isso sozinha: ela recebe o dia agregado, n\xE3o as
2268
+ -- batidas, e "a \xFAltima abertura sem par" \xE9 justamente o que a agrega\xE7\xE3o sabe e
2269
+ -- ela n\xE3o. Sem a coluna, a alternativa seria a tela buscar as batidas de cada
2270
+ -- linha \u2014 299 consultas para desenhar uma tabela.
2271
+ --
2272
+ -- O rel\xF3gio corre do lado de quem olha, n\xE3o do banco: a coluna \xE9 o INSTANTE, e
2273
+ -- o "6h58 em curso" \xE9 calculado no render. Um intervalo calculado aqui
2274
+ -- envelheceria entre a consulta e a pintura, e envelheceria de novo a cada
2275
+ -- segundo que a aba ficasse aberta.
2276
+ --
2277
+ -- \u2500\u2500 O que N\xC3O muda \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
2278
+ --
2279
+ -- Dia em aberto continua **fora** do total do dia e do per\xEDodo. A regra \xE9 a do
2280
+ -- V1 e est\xE1 certa: contar uma jornada que n\xE3o terminou inventa hora que ningu\xE9m
2281
+ -- trabalhou. O que muda \xE9 que agora o n\xFAmero que ele n\xE3o soma tamb\xE9m n\xE3o \xE9
2282
+ -- absurdo quando algu\xE9m olha a linha.
2283
+ --
2284
+ -- Idempotente e replay-safe.
2285
+ -- ---------------------------------------------------------------------------
2286
+
2287
+ CREATE OR REPLACE VIEW public.v_workforce_day
2288
+ WITH (security_invoker = 'true') AS
2289
+ WITH bruto AS (
2290
+ SELECT e.tenant_id,
2291
+ e.unit_id,
2292
+ e.person_id,
2293
+ e.work_date,
2294
+ min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,
2295
+ max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,
2296
+ count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,
2297
+ count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,
2298
+ count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,
2299
+ -- A \xFAltima ABERTURA do dia. Quando as contagens n\xE3o batem, \xE9 ela que
2300
+ -- ficou sem par \u2014 os eventos de um dia chegam em ordem, e uma abertura
2301
+ -- anterior j\xE1 teria sido fechada pela seguinte.
2302
+ max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,
2303
+ sum(CASE
2304
+ WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)
2305
+ WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)
2306
+ END) AS soma_com_sinal
2307
+ FROM public.v_workforce_time_entries e
2308
+ WHERE NOT e.voided
2309
+ GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date
2310
+ ),
2311
+ realized AS (
2312
+ SELECT b.tenant_id,
2313
+ b.unit_id,
2314
+ b.person_id,
2315
+ b.work_date,
2316
+ b.first_in,
2317
+ b.last_out,
2318
+ b.break_count,
2319
+ (b.aberturas <> b.fechamentos) AS is_open,
2320
+ CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,
2321
+ -- Devolve \xE0 soma a abertura sem par. Sem isto o que sobra \xE9 o epoch
2322
+ -- dela, com sinal trocado, e foi o que a tela vinha mostrando.
2323
+ (b.soma_com_sinal
2324
+ + CASE WHEN b.aberturas <> b.fechamentos
2325
+ THEN extract(epoch FROM b.ultima_abertura)
2326
+ ELSE 0 END) AS worked_seconds
2327
+ FROM bruto b
2328
+ ),
2329
+ planned AS (
2330
+ SELECT a.tenant_id,
2331
+ a.unit_id,
2332
+ a.person_id,
2333
+ s.schedule_date AS work_date,
2334
+ a.id AS assignment_id,
2335
+ a.role,
2336
+ a.status AS assignment_status,
2337
+ a.shift_label,
2338
+ a.shift_start_time,
2339
+ a.shift_end_time,
2340
+ -- O turno que vira o dia \xE9 normal aqui; sem o +24h a jornada da noite
2341
+ -- daria negativa.
2342
+ (extract(epoch FROM a.shift_end_time - a.shift_start_time)
2343
+ + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds
2344
+ FROM public.plg_workforce_schedule_assignments a
2345
+ JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id
2346
+ WHERE s.status IN ('published', 'closed')
2347
+ AND a.role = 'titular'
2348
+ AND a.shift_start_time IS NOT NULL
2349
+ AND a.shift_end_time IS NOT NULL
2350
+ )
2351
+ SELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,
2352
+ coalesce(p.unit_id, r.unit_id) AS unit_id,
2353
+ coalesce(p.person_id, r.person_id) AS person_id,
2354
+ coalesce(p.work_date, r.work_date) AS work_date,
2355
+ p.assignment_id,
2356
+ p.shift_label,
2357
+ p.shift_start_time,
2358
+ p.shift_end_time,
2359
+ p.assignment_status,
2360
+ (p.planned_seconds / 60)::integer AS planned_minutes,
2361
+ (r.worked_seconds / 60)::integer AS worked_minutes,
2362
+ ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,
2363
+ r.first_in,
2364
+ r.last_out,
2365
+ r.break_count,
2366
+ coalesce(r.is_open, false) AS is_open,
2367
+ (r.person_id IS NULL) AS absent,
2368
+ (p.assignment_id IS NULL) AS outside_schedule,
2369
+ -- Coluna nova, no fim: \`CREATE OR REPLACE VIEW\` s\xF3 aceita acrescentar
2370
+ -- aqui, e \xE9 de prop\xF3sito \u2014 quem l\xEA por posi\xE7\xE3o n\xE3o se move.
2371
+ r.open_since
2372
+ FROM planned p
2373
+ FULL OUTER JOIN realized r
2374
+ ON r.tenant_id = p.tenant_id
2375
+ AND r.person_id = p.person_id
2376
+ AND r.work_date = p.work_date;
2377
+
2378
+ COMMENT ON VIEW public.v_workforce_day IS
2379
+ 'Jornada prevista \xD7 realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: \`absent\` \xE9 escala sem ponto, \`outside_schedule\` \xE9 ponto sem escala. \`worked_minutes\` conta s\xF3 os trechos FECHADOS; \`open_since\` \xE9 a abertura que ainda n\xE3o tem par (003).';
2380
+
2381
+ COMMENT ON COLUMN public.v_workforce_day.open_since IS
2382
+ 'O instante da abertura sem par, quando o ponto est\xE1 aberto (003). \xC9 o INSTANTE e n\xE3o a dura\xE7\xE3o: o rel\xF3gio corre do lado de quem olha a tela, e um intervalo calculado aqui envelheceria entre a consulta e a pintura.';
2383
+
2384
+ GRANT SELECT ON public.v_workforce_day TO authenticated, service_role;
2385
+ `;
2386
+ var MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA = `-- ---------------------------------------------------------------------------
2387
+ -- 004_a_jornada_implausivel_nao_soma.sql \u2014 45h45 num dia.
2388
+ --
2389
+ -- Medido em 19/09/2026 no tenant Pertinho do C\xE9u, na tela de Registros de Ponto:
2390
+ --
2391
+ -- Steffany Fidelis da Costa 15:41 \u2192 13:26 45h45
2392
+ -- CRISTIANE CARDOSO OLIVEIRA 17:14 \u2192 09:23 16h10
2393
+ -- jannyne gomes dos santos 09:07 \u2192 08:58 15h56
2394
+ --
2395
+ -- e o cabe\xE7alho do dia somando **109h45** para oito pessoas.
2396
+ --
2397
+ -- Nenhum desses \xE9 erro de conta: a 003 j\xE1 consertou a soma, e esses dias est\xE3o
2398
+ -- FECHADOS. Eles s\xE3o a medi\xE7\xE3o correta de um par errado \u2014 algu\xE9m entrou, foi
2399
+ -- embora sem bater a sa\xEDda, e a sa\xEDda caiu horas ou dias depois. O V1 grava
2400
+ -- \`checkin_at\` e \`checkout_at\` como um par s\xF3, ent\xE3o o buraco atravessou inteiro.
2401
+ --
2402
+ -- \u2500\u2500 Por que n\xE3o um teto de 8h \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
2403
+ --
2404
+ -- Trocar 45h45 por 8h faria o total parecer certo. \xC9 o pior resultado poss\xEDvel:
2405
+ --
2406
+ -- \xB7 8h ali \xE9 um n\xFAmero que ningu\xE9m trabalhou e ningu\xE9m digitou \u2014 \xE9 a regra
2407
+ -- "nada de default inventado" quebrada no lugar onde ela mais custa, que \xE9
2408
+ -- base de c\xE1lculo de folha;
2409
+ -- \xB7 e o dia deixaria de parecer estranho, ent\xE3o a batida furada nunca seria
2410
+ -- corrigida. O erro sai da tela e fica no dado, que \xE9 o contr\xE1rio de
2411
+ -- resolver.
2412
+ --
2413
+ -- \u2500\u2500 O que esta migration faz \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
2414
+ --
2415
+ -- Estende a regra que a tela J\xC1 tem. Ponto em aberto n\xE3o soma, porque contar
2416
+ -- uma jornada que n\xE3o terminou inventa hora. Um dia de 45h \xE9 o mesmo problema
2417
+ -- com outra cara \u2014 um par em que n\xE3o se pode confiar \u2014, ent\xE3o ele tamb\xE9m n\xE3o
2418
+ -- soma. O valor medido continua \xE0 vista, inteiro, com o motivo escrito ao lado.
2419
+ --
2420
+ -- \`review_reason\` nomeia por que o dia n\xE3o entra na conta:
2421
+ --
2422
+ -- 'open' ainda n\xE3o terminou
2423
+ -- 'span_too_long' fechou, e fechou com dura\xE7\xE3o que n\xE3o \xE9 uma jornada
2424
+ --
2425
+ -- NULL \xE9 o dia que conta. Uma coluna s\xF3, e quem soma filtra por ela \u2014 em vez de
2426
+ -- cada relat\xF3rio redescobrir a regra e descobrir diferente.
2427
+ --
2428
+ -- \u2500\u2500 O limite, e por que dezesseis horas \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
2429
+ --
2430
+ -- A jornada mais longa do vocabul\xE1rio do V1 \xE9 a escala 12x36: doze horas. Com
2431
+ -- duas de extra, quatorze. Dezesseis deixa margem para o turno que vira a noite
2432
+ -- e ainda separa com folga o caso real, que n\xE3o \xE9 uma jornada comprida e sim
2433
+ -- uma sa\xEDda que ficou pendurada \u2014 os tr\xEAs medidos acima t\xEAm 16h, 16h e 46h.
2434
+ --
2435
+ -- \xC9 o MESMO dezesseis que a tela usa para dizer "sem sa\xEDda h\xE1 31h" num ponto
2436
+ -- aberto, e \xE9 de prop\xF3sito: um n\xFAmero, uma justificativa, os dois lados da
2437
+ -- mesma pergunta.
2438
+ --
2439
+ -- Idempotente e replay-safe.
2440
+ -- ---------------------------------------------------------------------------
2441
+
2442
+ CREATE OR REPLACE FUNCTION public.workforce_max_shift_minutes()
2443
+ RETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE
2444
+ AS $$ SELECT 16 * 60 $$;
2445
+
2446
+ COMMENT ON FUNCTION public.workforce_max_shift_minutes() IS
2447
+ 'Acima disto uma jornada fechada deixa de ser jornada e vira sa\xEDda n\xE3o batida (004). 12x36 \xE9 a mais longa do vocabul\xE1rio do V1; dezesseis d\xE1 margem ao turno que vira a noite. Fun\xE7\xE3o e n\xE3o constante literal para que a tela, a view e um relat\xF3rio futuro citem o mesmo n\xFAmero.';
2448
+
2449
+ CREATE OR REPLACE VIEW public.v_workforce_day
2450
+ WITH (security_invoker = 'true') AS
2451
+ WITH bruto AS (
2452
+ SELECT e.tenant_id,
2453
+ e.unit_id,
2454
+ e.person_id,
2455
+ e.work_date,
2456
+ min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,
2457
+ max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,
2458
+ count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,
2459
+ count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,
2460
+ count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,
2461
+ -- A \xFAltima ABERTURA do dia. Quando as contagens n\xE3o batem, \xE9 ela que
2462
+ -- ficou sem par \u2014 os eventos de um dia chegam em ordem, e uma abertura
2463
+ -- anterior j\xE1 teria sido fechada pela seguinte.
2464
+ max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,
2465
+ sum(CASE
2466
+ WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)
2467
+ WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)
2468
+ END) AS soma_com_sinal
2469
+ FROM public.v_workforce_time_entries e
2470
+ WHERE NOT e.voided
2471
+ GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date
2472
+ ),
2473
+ realized AS (
2474
+ SELECT b.tenant_id,
2475
+ b.unit_id,
2476
+ b.person_id,
2477
+ b.work_date,
2478
+ b.first_in,
2479
+ b.last_out,
2480
+ b.break_count,
2481
+ (b.aberturas <> b.fechamentos) AS is_open,
2482
+ CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,
2483
+ -- Devolve \xE0 soma a abertura sem par (003). Sem isto o que sobra \xE9 o
2484
+ -- epoch dela, com sinal trocado.
2485
+ (b.soma_com_sinal
2486
+ + CASE WHEN b.aberturas <> b.fechamentos
2487
+ THEN extract(epoch FROM b.ultima_abertura)
2488
+ ELSE 0 END) AS worked_seconds
2489
+ FROM bruto b
2490
+ ),
2491
+ planned AS (
2492
+ SELECT a.tenant_id,
2493
+ a.unit_id,
2494
+ a.person_id,
2495
+ s.schedule_date AS work_date,
2496
+ a.id AS assignment_id,
2497
+ a.role,
2498
+ a.status AS assignment_status,
2499
+ a.shift_label,
2500
+ a.shift_start_time,
2501
+ a.shift_end_time,
2502
+ -- O turno que vira o dia \xE9 normal aqui; sem o +24h a jornada da noite
2503
+ -- daria negativa.
2504
+ (extract(epoch FROM a.shift_end_time - a.shift_start_time)
2505
+ + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds
2506
+ FROM public.plg_workforce_schedule_assignments a
2507
+ JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id
2508
+ WHERE s.status IN ('published', 'closed')
2509
+ AND a.role = 'titular'
2510
+ AND a.shift_start_time IS NOT NULL
2511
+ AND a.shift_end_time IS NOT NULL
2512
+ )
2513
+ SELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,
2514
+ coalesce(p.unit_id, r.unit_id) AS unit_id,
2515
+ coalesce(p.person_id, r.person_id) AS person_id,
2516
+ coalesce(p.work_date, r.work_date) AS work_date,
2517
+ p.assignment_id,
2518
+ p.shift_label,
2519
+ p.shift_start_time,
2520
+ p.shift_end_time,
2521
+ p.assignment_status,
2522
+ (p.planned_seconds / 60)::integer AS planned_minutes,
2523
+ -- O MEDIDO, sempre. N\xE3o \xE9 aqui que se decide se ele conta \u2014 a coluna
2524
+ -- mente se for corrigida, e um n\xFAmero corrigido em sil\xEAncio \xE9 pior que
2525
+ -- um n\xFAmero estranho.
2526
+ (r.worked_seconds / 60)::integer AS worked_minutes,
2527
+ ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,
2528
+ r.first_in,
2529
+ r.last_out,
2530
+ r.break_count,
2531
+ coalesce(r.is_open, false) AS is_open,
2532
+ (r.person_id IS NULL) AS absent,
2533
+ (p.assignment_id IS NULL) AS outside_schedule,
2534
+ r.open_since,
2535
+ -- Por que este dia n\xE3o entra na soma. NULL = entra.
2536
+ CASE
2537
+ WHEN r.person_id IS NULL THEN NULL -- escala sem ponto: n\xE3o h\xE1 o que somar
2538
+ WHEN r.is_open THEN 'open'
2539
+ WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()
2540
+ THEN 'span_too_long'
2541
+ END AS review_reason
2542
+ FROM planned p
2543
+ FULL OUTER JOIN realized r
2544
+ ON r.tenant_id = p.tenant_id
2545
+ AND r.person_id = p.person_id
2546
+ AND r.work_date = p.work_date;
2547
+
2548
+ COMMENT ON VIEW public.v_workforce_day IS
2549
+ 'Jornada prevista \xD7 realizada por pessoa e dia. \`worked_minutes\` \xE9 sempre o MEDIDO; \`review_reason\` diz por que um dia n\xE3o entra na soma \u2014 \`open\` (n\xE3o terminou) ou \`span_too_long\` (fechou com dura\xE7\xE3o que n\xE3o \xE9 jornada, 004). Quem soma filtra por ela.';
2550
+
2551
+ COMMENT ON COLUMN public.v_workforce_day.review_reason IS
2552
+ 'NULL = o dia conta. \`open\` = ponto em aberto. \`span_too_long\` = acima de workforce_max_shift_minutes(), que na pr\xE1tica \xE9 sa\xEDda n\xE3o batida e n\xE3o jornada longa (004).';
2553
+
2554
+ GRANT SELECT ON public.v_workforce_day TO authenticated, service_role;
2555
+ `;
2556
+ var MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR = `-- ---------------------------------------------------------------------------
2557
+ -- 005_a_ficha_trabalhista_tem_onde_morar.sql \u2014 o RH do V1 ganha destino.
2558
+ --
2559
+ -- Escrito a partir de \`~/dev/beautyplace/docs/v1-guide/rh\`, que documenta o
2560
+ -- m\xF3dulo do V1 andando nele: a Vis\xE3o Geral (oito cart\xF5es, tr\xEAs listas) e a Ficha
2561
+ -- de RH da pessoa (dezesseis vistas). O FLOW de cada um diz \`v2: status:
2562
+ -- missing\` \u2014 e dizia certo: n\xE3o havia nada disso aqui.
2563
+ --
2564
+ -- \u2500\u2500 O que este arquivo N\xC3O traz, 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\u2500\u2500\u2500
2565
+ --
2566
+ -- N\xE3o s\xE3o as dezesseis vistas. Medido na origem em 19/09/2026, nas 886 pessoas
2567
+ -- dos tr\xEAs tenants em porte:
2568
+ --
2569
+ -- as 12 tabelas de lista da Ficha 0 linhas \u2014 no V1 INTEIRO, n\xE3o s\xF3
2570
+ -- (f\xE9rias, faltas, advert\xEAncias, nesses tenants
2571
+ -- treinamentos, avalia\xE7\xF5es, exames,
2572
+ -- EPIs, pend\xEAncias, dependentes,
2573
+ -- hist\xF3rico salarial, 13\xBA, benef\xEDcios)
2574
+ -- admission_date, dismissal_date, 0 preenchidos
2575
+ -- job_title, pis_pasep, CTPS, eSocial
2576
+ -- base_salary 25 de 422
2577
+ -- contract_type 2
2578
+ --
2579
+ -- O pr\xF3prio FLOW da Vis\xE3o Geral avisa: *"Num tenant rec\xE9m-migrado ele mostra
2580
+ -- headcount cheio e todo o resto zerado \u2014 e isso n\xE3o \xE9 defeito, \xE9 ficha vazia."*
2581
+ -- Aqui TODO tenant \xE9 assim. Desenhar dezesseis vistas para portar zero linha
2582
+ -- seria construir um formul\xE1rio de cada vez que algu\xE9m leu um FLOW.
2583
+ --
2584
+ -- Ent\xE3o vem o n\xFAcleo que sustenta o conceito e j\xE1 \xE9 \xFAtil no primeiro dia:
2585
+ --
2586
+ -- employment o v\xEDnculo \u2014 \xE9 dele que saem headcount, admiss\xF5es,
2587
+ -- demiss\xF5es, turnover e experi\xEAncia vencendo. Sem ele
2588
+ -- nenhum n\xFAmero de RH existe, e \xE9 a vista 1 do V1
2589
+ -- vacations f\xE9rias. Obriga\xE7\xE3o legal, recorrente, e a \xFAnica lista do
2590
+ -- V1 que alimenta um cart\xE3o
2591
+ -- absences faltas e afastamentos
2592
+ -- pending_items pend\xEAncias \u2014 a outra lista que aparece no painel
2593
+ --
2594
+ -- As outras doze entram quando algu\xE9m for us\xE1-las. O que elas precisam j\xE1 est\xE1
2595
+ -- de p\xE9: a forma da tabela, o escopo, a permiss\xE3o e a view do painel.
2596
+ --
2597
+ -- \u2500\u2500 A permiss\xE3o, que o FLOW manda resolver ANTES \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
2598
+ --
2599
+ -- V1-069: no V1 **n\xE3o h\xE1 permiss\xE3o separada para RH**. Quem edita cadastro v\xEA
2600
+ -- sal\xE1rio, CPF, dependentes e CID de atestado. O FLOW da Ficha fecha dizendo que
2601
+ -- portar s\xF3 vale "desde que a quest\xE3o da permiss\xE3o separada seja resolvida
2602
+ -- antes" \u2014 ent\xE3o ela \xE9 resolvida aqui, e n\xE3o depois.
2603
+ --
2604
+ -- \`workforce_hr.*\` \xE9 uma fam\xEDlia pr\xF3pria: quem tem \`workforce.read\` v\xEA escala e
2605
+ -- ponto e N\xC3O v\xEA sal\xE1rio. S\xE3o duas perguntas diferentes e agora t\xEAm duas
2606
+ -- respostas.
2607
+ --
2608
+ -- \u2500\u2500 O que o V2 faz e o V1 n\xE3o \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
2609
+ --
2610
+ -- O FLOW da vista 10 registra, como defeito: *"N\xE3o conversa com o ponto
2611
+ -- eletr\xF4nico nem com a escala: uma falta lan\xE7ada aqui n\xE3o aparece l\xE1."* No V1
2612
+ -- s\xE3o dois sistemas sobre a mesma pessoa que n\xE3o se olham \u2014 algu\xE9m de atestado
2613
+ -- aparece como FALTA na folha de ponto, e quem confere precisa lembrar de cabe\xE7a.
2614
+ --
2615
+ -- A \xA77 conserta: \`v_workforce_day\` passa a saber do afastamento. Um dia coberto
2616
+ -- por atestado deixa de ser "Falta" e passa a dizer o que foi. \xC9 a raz\xE3o de o RH
2617
+ -- morar no mesmo plugin do ponto, e n\xE3o ao lado dele.
2618
+ --
2619
+ -- Idempotente e replay-safe.
2620
+ -- ---------------------------------------------------------------------------
2621
+
2622
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2623
+ -- 1. A PERMISS\xC3O SEPARADA (V1-069)
2624
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2625
+
2626
+ INSERT INTO app.permissions (key, plugin, description) VALUES
2627
+ ('workforce_hr.read', 'workforce_hr', 'Ver a ficha trabalhista: v\xEDnculo, sal\xE1rio, f\xE9rias, afastamentos e pend\xEAncias.'),
2628
+ ('workforce_hr.create', 'workforce_hr', 'Lan\xE7ar na ficha trabalhista.'),
2629
+ ('workforce_hr.edit', 'workforce_hr', 'Corrigir lan\xE7amento da ficha trabalhista.'),
2630
+ ('workforce_hr.delete', 'workforce_hr', 'Excluir lan\xE7amento da ficha trabalhista.')
2631
+ ON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = EXCLUDED.description;
2632
+
2633
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2634
+ -- 2. O V\xCDNCULO
2635
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2636
+ --
2637
+ -- Uma linha por pessoa, e n\xE3o colunas em \`public.people\`. Tr\xEAs raz\xF5es, e a
2638
+ -- terceira \xE9 a que decide:
2639
+ --
2640
+ -- \xB7 \`people\` \xE9 do core e atende cliente, fornecedor e profissional; quarenta
2641
+ -- colunas de CLT ali seriam nulas para a esmagadora maioria das linhas;
2642
+ -- \xB7 o V1 tem DUAS tabelas (professionals, employees) com os mesmos campos, e
2643
+ -- o V2 tem uma pessoa s\xF3 \u2014 a separa\xE7\xE3o \xE9 papel, n\xE3o cadastro;
2644
+ -- \xB7 e \xE9 a tabela que carrega o sal\xE1rio. Ela precisa de uma policy PR\xD3PRIA,
2645
+ -- e uma coluna n\xE3o tem policy: RLS \xE9 por tabela. Guardar sal\xE1rio em
2646
+ -- \`people\` seria reproduzir o V1-069 no schema.
2647
+
2648
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_employment (
2649
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2650
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2651
+ unit_id uuid,
2652
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
2653
+
2654
+ -- \u2500\u2500 v\xEDnculo (vista 1 do V1: Contrato) \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
2655
+ -- Vocabul\xE1rio exato do FLOW. Lista fechada porque \xE9 ela que decide encargo, e
2656
+ -- "CLT " com espa\xE7o vira um tipo de contrato novo em sil\xEAncio.
2657
+ contract_kind text CHECK (contract_kind IN
2658
+ ('clt', 'pj', 'estagio', 'jovem_aprendiz', 'autonomo', 'socio', 'temporario', 'intermitente')),
2659
+ admission_date date,
2660
+ dismissal_date date,
2661
+ dismissal_reason text,
2662
+ -- O V1 tem os dois: o fim da PRIMEIRA experi\xEAncia e o fim FINAL. \xC9 o segundo
2663
+ -- que alimenta o cart\xE3o "Experi\xEAncia vencendo".
2664
+ probation_first_end date,
2665
+ probation_final_end date,
2666
+ job_title text,
2667
+ department text,
2668
+ schedule_regime text CHECK (schedule_regime IN
2669
+ ('mensalista', 'horista', 'diarista', 'escala_12x36', 'escala_6x1', 'escala_5x2')),
2670
+ weekly_hours numeric(5,2),
2671
+ monthly_hours numeric(6,2),
2672
+ esocial_registration text,
2673
+
2674
+ -- \u2500\u2500 remunera\xE7\xE3o (vista 2) \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
2675
+ -- Valor de REFER\xCANCIA, e o V1 diz isso com todas as letras: nada aqui vira
2676
+ -- conta a pagar, comiss\xE3o ou desconto. O coment\xE1rio fica porque a coluna
2677
+ -- chamada \`base_salary\` convida a supor o contr\xE1rio.
2678
+ base_salary numeric(14,2),
2679
+ pay_frequency text CHECK (pay_frequency IN ('mensal', 'quinzenal', 'semanal', 'diario', 'por_hora')),
2680
+ -- Os quinze aux\xEDlios e adicionais da vista 2 em jsonb, e n\xE3o em quinze
2681
+ -- colunas. Medido: ZERO deles preenchido nas 886 pessoas. Quinze colunas
2682
+ -- nulas seriam quinze decis\xF5es de schema tomadas sem um caso de uso; a chave
2683
+ -- que a casa usar aparece aqui e vira coluna quando pagar a passagem.
2684
+ allowances jsonb NOT NULL DEFAULT '{}'::jsonb,
2685
+
2686
+ notes text,
2687
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
2688
+ created_at timestamptz NOT NULL DEFAULT now(),
2689
+ updated_at timestamptz NOT NULL DEFAULT now(),
2690
+ -- Um v\xEDnculo por pessoa. Recontrata\xE7\xE3o reabre o mesmo, com a data nova \u2014 o
2691
+ -- hist\xF3rico de quem saiu e voltou \xE9 uma vista de lista que ainda n\xE3o existe,
2692
+ -- e inventar a segunda linha agora criaria dois headcounts para uma pessoa.
2693
+ CONSTRAINT plg_workforce_employment_person UNIQUE (tenant_id, person_id),
2694
+ -- Demiss\xE3o antes da admiss\xE3o n\xE3o \xE9 dado ruim, \xE9 dado imposs\xEDvel.
2695
+ CONSTRAINT plg_workforce_employment_dates
2696
+ CHECK (dismissal_date IS NULL OR admission_date IS NULL OR dismissal_date >= admission_date)
2697
+ );
2698
+
2699
+ COMMENT ON TABLE public.plg_workforce_employment IS
2700
+ 'O v\xEDnculo trabalhista de uma pessoa (005). \xC9 dele que saem headcount, admiss\xF5es, demiss\xF5es, turnover e experi\xEAncia vencendo. Tabela pr\xF3pria e n\xE3o colunas em people: RLS \xE9 por tabela, e \xE9 o sal\xE1rio que precisa da policy.';
2701
+ COMMENT ON COLUMN public.plg_workforce_employment.base_salary IS
2702
+ 'Valor de REFER\xCANCIA. N\xE3o vira conta a pagar, comiss\xE3o nem desconto \u2014 o V1 tamb\xE9m n\xE3o fazia isso, e o nome da coluna convida a supor que faz.';
2703
+ COMMENT ON COLUMN public.plg_workforce_employment.allowances IS
2704
+ 'Aux\xEDlios e adicionais (VT, VR, VA, plano, creche, noturno, insalubridade\u2026). Em jsonb porque os quinze campos da vista 2 do V1 est\xE3o vazios nas 886 pessoas medidas: vira coluna quando algu\xE9m pagar a passagem.';
2705
+
2706
+ CREATE INDEX IF NOT EXISTS plg_workforce_employment_tenant_person
2707
+ ON public.plg_workforce_employment (tenant_id, person_id);
2708
+ CREATE INDEX IF NOT EXISTS plg_workforce_employment_ativo
2709
+ ON public.plg_workforce_employment (tenant_id) WHERE dismissal_date IS NULL;
2710
+
2711
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2712
+ -- 3. F\xC9RIAS (vista 8)
2713
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2714
+
2715
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_vacations (
2716
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2717
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2718
+ unit_id uuid,
2719
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
2720
+ -- O per\xEDodo AQUISITIVO: o que a pessoa ganhou. \xC9 o \`accrual_end\` que alimenta
2721
+ -- "F\xE9rias vencendo (60d)".
2722
+ accrual_start date NOT NULL,
2723
+ accrual_end date NOT NULL,
2724
+ -- O per\xEDodo de GOZO: quando ela tirou. Nulo \xE9 o estado normal de quem ainda
2725
+ -- n\xE3o programou.
2726
+ taken_start date,
2727
+ taken_end date,
2728
+ days_taken integer,
2729
+ days_sold integer,
2730
+ advance_13th boolean NOT NULL DEFAULT false,
2731
+ -- Digitado, como no V1. O sistema N\xC3O calcula o ter\xE7o \u2014 e escrever isso aqui
2732
+ -- \xE9 o que impede algu\xE9m de supor que a coluna vazia \xE9 um bug.
2733
+ constitutional_third numeric(14,2),
2734
+ status text NOT NULL DEFAULT 'pendente'
2735
+ CHECK (status IN ('pendente', 'programada', 'em_gozo', 'concluida', 'vencida')),
2736
+ notes text,
2737
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
2738
+ created_at timestamptz NOT NULL DEFAULT now(),
2739
+ updated_at timestamptz NOT NULL DEFAULT now(),
2740
+ CONSTRAINT plg_workforce_vacations_accrual CHECK (accrual_end >= accrual_start),
2741
+ CONSTRAINT plg_workforce_vacations_taken
2742
+ CHECK (taken_end IS NULL OR taken_start IS NULL OR taken_end >= taken_start)
2743
+ );
2744
+
2745
+ COMMENT ON TABLE public.plg_workforce_vacations IS
2746
+ 'Per\xEDodo aquisitivo e de gozo de f\xE9rias (005). Nada \xE9 calculado, inclusive o ter\xE7o constitucional \u2014 \xE9 digitado, como no V1.';
2747
+
2748
+ CREATE INDEX IF NOT EXISTS plg_workforce_vacations_vencendo
2749
+ ON public.plg_workforce_vacations (tenant_id, accrual_end) WHERE status <> 'concluida';
2750
+
2751
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2752
+ -- 4. FALTAS E AFASTAMENTOS (vista 10)
2753
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2754
+
2755
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_absences (
2756
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2757
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2758
+ unit_id uuid,
2759
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
2760
+ kind text NOT NULL CHECK (kind IN
2761
+ ('falta_justificada', 'falta_injustificada', 'atestado', 'licenca_maternidade',
2762
+ 'licenca_paternidade', 'licenca_nao_remunerada', 'suspensao', 'ferias', 'afastamento_inss')),
2763
+ starts_on date NOT NULL,
2764
+ -- Nulo = afastamento em aberto, que \xE9 um estado real (INSS sem alta marcada).
2765
+ ends_on date,
2766
+ days integer,
2767
+ paid boolean NOT NULL DEFAULT true,
2768
+ -- Dado m\xE9dico. Mora aqui e n\xE3o em \`people\` pelo mesmo motivo do sal\xE1rio: \xE9 a
2769
+ -- policy de \`workforce_hr\` que o protege, e V1-069 \xE9 exatamente sobre isto.
2770
+ icd_code text,
2771
+ doctor text,
2772
+ notes text,
2773
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
2774
+ created_at timestamptz NOT NULL DEFAULT now(),
2775
+ updated_at timestamptz NOT NULL DEFAULT now(),
2776
+ CONSTRAINT plg_workforce_absences_span CHECK (ends_on IS NULL OR ends_on >= starts_on)
2777
+ );
2778
+
2779
+ COMMENT ON TABLE public.plg_workforce_absences IS
2780
+ 'Falta e afastamento (005). Diferente do V1, CONVERSA com o ponto: v_workforce_day l\xEA daqui, e um dia coberto por atestado deixa de aparecer como falta.';
2781
+ COMMENT ON COLUMN public.plg_workforce_absences.icd_code IS
2782
+ 'CID do atestado. Dado de sa\xFAde: \xE9 a policy de workforce_hr que o separa de quem edita cadastro \u2014 o V1 n\xE3o separava (V1-069).';
2783
+
2784
+ CREATE INDEX IF NOT EXISTS plg_workforce_absences_periodo
2785
+ ON public.plg_workforce_absences (tenant_id, person_id, starts_on, ends_on);
2786
+
2787
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2788
+ -- 5. PEND\xCANCIAS (vista 16)
2789
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2790
+
2791
+ CREATE TABLE IF NOT EXISTS public.plg_workforce_pending_items (
2792
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2793
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2794
+ unit_id uuid,
2795
+ person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,
2796
+ title text NOT NULL,
2797
+ category text CHECK (category IN ('documento', 'exame', 'epi', 'treinamento', 'assinatura', 'outro')),
2798
+ priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('baixa', 'normal', 'alta', 'urgente')),
2799
+ due_on date,
2800
+ resolved_at timestamptz,
2801
+ description text,
2802
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
2803
+ created_at timestamptz NOT NULL DEFAULT now(),
2804
+ updated_at timestamptz NOT NULL DEFAULT now()
2805
+ );
2806
+
2807
+ COMMENT ON TABLE public.plg_workforce_pending_items IS
2808
+ 'O que falta na pasta de algu\xE9m: documento, exame, EPI, assinatura (005). \xC9 a lista que o painel cobra \u2014 "Pend\xEAncias cr\xEDticas" conta as n\xE3o resolvidas de prioridade alta ou urgente.';
2809
+
2810
+ CREATE INDEX IF NOT EXISTS plg_workforce_pending_abertas
2811
+ ON public.plg_workforce_pending_items (tenant_id, priority, due_on) WHERE resolved_at IS NULL;
2812
+
2813
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2814
+ -- 6. ESCOPO, CARIMBO E PERMISS\xC3O
2815
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2816
+
2817
+ DO $$
2818
+ DECLARE r record;
2819
+ BEGIN
2820
+ FOR r IN SELECT * FROM (VALUES
2821
+ ('plg_workforce_employment', 'workforce_hr.employment'),
2822
+ ('plg_workforce_vacations', 'workforce_hr.vacation'),
2823
+ ('plg_workforce_absences', 'workforce_hr.absence'),
2824
+ ('plg_workforce_pending_items', 'workforce_hr.pending_item')
2825
+ ) AS t(tbl, resource)
2826
+ LOOP
2827
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.tbl);
2828
+ EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.tbl);
2829
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);
2830
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',
2831
+ r.tbl || '_updated_at', r.tbl);
2832
+ -- \`workforce_hr\` e n\xE3o \`workforce\`: \xE9 o nome do plugin que o scaffold usa
2833
+ -- para derivar \`<plugin>.read/create/edit/delete\`, ent\xE3o passar outro nome
2834
+ -- aqui \xC9 a separa\xE7\xE3o de permiss\xE3o. Quem tem workforce.read v\xEA escala e
2835
+ -- ponto e n\xE3o v\xEA sal\xE1rio.
2836
+ PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce_hr', false, 'enforce');
2837
+ END LOOP;
2838
+ END $$;
2839
+
2840
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2841
+ -- 7. O PONTO PASSA A SABER DO AFASTAMENTO
2842
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2843
+ --
2844
+ -- O defeito que o FLOW da vista 10 registra: *"N\xE3o conversa com o ponto
2845
+ -- eletr\xF4nico nem com a escala."* Duas telas sobre a mesma pessoa, no mesmo
2846
+ -- sistema, que n\xE3o se olham \u2014 quem confere ponto v\xEA FALTA em cima de um
2847
+ -- atestado que o RH lan\xE7ou na semana passada, e precisa lembrar de cabe\xE7a.
2848
+ --
2849
+ -- A view n\xE3o muda de forma: ganha uma coluna no fim. \`absent\` continua sendo
2850
+ -- escala sem ponto, porque \xE9 isso que ele \xE9; o que muda \xE9 que agora d\xE1 para
2851
+ -- dizer POR QUE, quando algu\xE9m sabe.
2852
+
2853
+ CREATE OR REPLACE VIEW public.v_workforce_day
2854
+ WITH (security_invoker = 'true') AS
2855
+ WITH bruto AS (
2856
+ SELECT e.tenant_id, e.unit_id, e.person_id, e.work_date,
2857
+ min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,
2858
+ max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,
2859
+ count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,
2860
+ count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,
2861
+ count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,
2862
+ max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,
2863
+ sum(CASE
2864
+ WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)
2865
+ WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)
2866
+ END) AS soma_com_sinal
2867
+ FROM public.v_workforce_time_entries e
2868
+ WHERE NOT e.voided
2869
+ GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date
2870
+ ),
2871
+ realized AS (
2872
+ SELECT b.tenant_id, b.unit_id, b.person_id, b.work_date,
2873
+ b.first_in, b.last_out, b.break_count,
2874
+ (b.aberturas <> b.fechamentos) AS is_open,
2875
+ CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,
2876
+ -- Devolve \xE0 soma a abertura sem par (003).
2877
+ (b.soma_com_sinal
2878
+ + CASE WHEN b.aberturas <> b.fechamentos
2879
+ THEN extract(epoch FROM b.ultima_abertura)
2880
+ ELSE 0 END) AS worked_seconds
2881
+ FROM bruto b
2882
+ ),
2883
+ planned AS (
2884
+ SELECT a.tenant_id, a.unit_id, a.person_id,
2885
+ s.schedule_date AS work_date,
2886
+ a.id AS assignment_id, a.role, a.status AS assignment_status,
2887
+ a.shift_label, a.shift_start_time, a.shift_end_time,
2888
+ (extract(epoch FROM a.shift_end_time - a.shift_start_time)
2889
+ + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds
2890
+ FROM public.plg_workforce_schedule_assignments a
2891
+ JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id
2892
+ WHERE s.status IN ('published', 'closed')
2893
+ AND a.role = 'titular'
2894
+ AND a.shift_start_time IS NOT NULL
2895
+ AND a.shift_end_time IS NOT NULL
2896
+ )
2897
+ SELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,
2898
+ coalesce(p.unit_id, r.unit_id) AS unit_id,
2899
+ coalesce(p.person_id, r.person_id) AS person_id,
2900
+ coalesce(p.work_date, r.work_date) AS work_date,
2901
+ p.assignment_id,
2902
+ p.shift_label,
2903
+ p.shift_start_time,
2904
+ p.shift_end_time,
2905
+ p.assignment_status,
2906
+ (p.planned_seconds / 60)::integer AS planned_minutes,
2907
+ (r.worked_seconds / 60)::integer AS worked_minutes,
2908
+ ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,
2909
+ r.first_in,
2910
+ r.last_out,
2911
+ r.break_count,
2912
+ coalesce(r.is_open, false) AS is_open,
2913
+ (r.person_id IS NULL) AS absent,
2914
+ (p.assignment_id IS NULL) AS outside_schedule,
2915
+ r.open_since,
2916
+ CASE
2917
+ WHEN r.person_id IS NULL THEN NULL
2918
+ WHEN r.is_open THEN 'open'
2919
+ WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()
2920
+ THEN 'span_too_long'
2921
+ END AS review_reason,
2922
+ -- Coluna nova, no fim. \`security_invoker\` na view + a policy de
2923
+ -- \`workforce_hr\` na tabela: quem n\xE3o tem a permiss\xE3o simplesmente n\xE3o
2924
+ -- enxerga o afastamento, e a coluna volta nula. A folha de ponto n\xE3o
2925
+ -- vira a porta dos fundos do CID.
2926
+ (SELECT a.kind
2927
+ FROM public.plg_workforce_absences a
2928
+ WHERE a.tenant_id = coalesce(p.tenant_id, r.tenant_id)
2929
+ AND a.person_id = coalesce(p.person_id, r.person_id)
2930
+ AND coalesce(p.work_date, r.work_date) BETWEEN a.starts_on
2931
+ AND coalesce(a.ends_on, coalesce(p.work_date, r.work_date))
2932
+ ORDER BY a.starts_on DESC
2933
+ LIMIT 1) AS absence_kind
2934
+ FROM planned p
2935
+ FULL OUTER JOIN realized r
2936
+ ON r.tenant_id = p.tenant_id
2937
+ AND r.person_id = p.person_id
2938
+ AND r.work_date = p.work_date;
2939
+
2940
+ COMMENT ON COLUMN public.v_workforce_day.absence_kind IS
2941
+ 'O afastamento que cobre este dia, quando o RH lan\xE7ou um (005). \xC9 o conserto do defeito que o V1 tem: l\xE1 a falta lan\xE7ada no RH n\xE3o aparece no ponto, e quem confere v\xEA FALTA em cima de um atestado.';
2942
+
2943
+ GRANT SELECT ON public.v_workforce_day TO authenticated, service_role;
2944
+
2945
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2946
+ -- 8. A VIS\xC3O GERAL \u2014 os oito n\xFAmeros
2947
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2948
+ --
2949
+ -- Os mesmos oito cart\xF5es do V1, com tr\xEAs diferen\xE7as, e as tr\xEAs s\xE3o conserto de
2950
+ -- defeito registrado no FLOW:
2951
+ --
2952
+ -- V1-072 "Experi\xEAncia vencendo" s\xF3 olhava PROFISSIONAIS, porque o V1 tem
2953
+ -- duas tabelas. O V2 tem uma pessoa, ent\xE3o olha todo mundo.
2954
+ -- V1-073 "F\xE9rias a vencer" mostrava a data e N\xC3O o nome. Aqui a view devolve
2955
+ -- person_id e nome, e a tela linka para a ficha.
2956
+ -- V1-074 aniversariante do dia 1\xBA ca\xEDa no m\xEAs anterior, por compara\xE7\xE3o de
2957
+ -- data em UTC. \`extract(month \u2026)\` n\xE3o tem esse problema.
2958
+
2959
+ CREATE OR REPLACE VIEW public.v_workforce_hr_overview
2960
+ WITH (security_invoker = 'true') AS
2961
+ WITH base AS (
2962
+ SELECT e.tenant_id, e.person_id, e.admission_date, e.dismissal_date, e.probation_final_end,
2963
+ p.name, p.date_of_birth, p.is_active
2964
+ FROM public.plg_workforce_employment e
2965
+ JOIN public.people p ON p.id = e.person_id
2966
+ )
2967
+ SELECT b.tenant_id,
2968
+ count(*) FILTER (WHERE b.dismissal_date IS NULL AND coalesce(b.is_active, true)) AS headcount,
2969
+ count(*) FILTER (WHERE b.admission_date >= current_date - 30) AS admissions_30d,
2970
+ count(*) FILTER (WHERE b.dismissal_date >= current_date - 30) AS dismissals_30d,
2971
+ count(*) FILTER (WHERE b.probation_final_end BETWEEN current_date AND current_date + 60) AS probation_ending,
2972
+ count(*) FILTER (WHERE b.dismissal_date IS NULL
2973
+ AND extract(month FROM b.date_of_birth) = extract(month FROM current_date)) AS birthdays_this_month,
2974
+ (SELECT count(*) FROM public.plg_workforce_vacations v
2975
+ WHERE v.tenant_id = b.tenant_id AND v.status <> 'concluida'
2976
+ AND v.accrual_end BETWEEN current_date AND current_date + 60) AS vacations_due_60d,
2977
+ (SELECT count(*) FROM public.plg_workforce_pending_items i
2978
+ WHERE i.tenant_id = b.tenant_id AND i.resolved_at IS NULL
2979
+ AND i.priority IN ('alta', 'urgente')) AS critical_pending
2980
+ FROM base b
2981
+ GROUP BY b.tenant_id;
2982
+
2983
+ COMMENT ON VIEW public.v_workforce_hr_overview IS
2984
+ 'Os oito n\xFAmeros do painel de RH do V1 (005). Turnover n\xE3o est\xE1 aqui de prop\xF3sito: \xE9 demiss\xF5es \xF7 headcount, e uma divis\xE3o que a tela faz \xE9 uma divis\xE3o que a tela pode formatar \u2014 a view devolve as duas parcelas.';
2985
+
2986
+ -- Quem vence, COM NOME \u2014 o V1-073 \xE9 justamente a lista sem nome.
2987
+ CREATE OR REPLACE VIEW public.v_workforce_hr_upcoming
2988
+ WITH (security_invoker = 'true') AS
2989
+ SELECT v.tenant_id, 'vacation'::text AS kind, v.person_id, p.name AS person_name,
2990
+ v.accrual_end AS due_on, v.status AS detail
2991
+ FROM public.plg_workforce_vacations v
2992
+ JOIN public.people p ON p.id = v.person_id
2993
+ WHERE v.status <> 'concluida' AND v.accrual_end BETWEEN current_date AND current_date + 60
2994
+ UNION ALL
2995
+ SELECT e.tenant_id, 'probation', e.person_id, p.name,
2996
+ e.probation_final_end, e.job_title
2997
+ FROM public.plg_workforce_employment e
2998
+ JOIN public.people p ON p.id = e.person_id
2999
+ WHERE e.probation_final_end BETWEEN current_date AND current_date + 60
3000
+ UNION ALL
3001
+ SELECT i.tenant_id, 'pending', i.person_id, p.name, i.due_on, i.title
3002
+ FROM public.plg_workforce_pending_items i
3003
+ JOIN public.people p ON p.id = i.person_id
3004
+ WHERE i.resolved_at IS NULL AND i.priority IN ('alta', 'urgente');
3005
+
3006
+ COMMENT ON VIEW public.v_workforce_hr_upcoming IS
3007
+ 'O que vence nos pr\xF3ximos 60 dias, com o NOME de quem (005). O V1 lista f\xE9rias a vencer s\xF3 com a data \u2014 V1-073 \u2014, o que obriga a procurar a pessoa em outra tela.';
3008
+
3009
+ GRANT SELECT ON public.v_workforce_hr_overview TO authenticated, service_role;
3010
+ GRANT SELECT ON public.v_workforce_hr_upcoming TO authenticated, service_role;
3011
+ `;
3012
+ var MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO = `-- O custo de pessoal \xE9 um RAMO DO PLANO, e quem o aponta \xE9 a casa.
3013
+ --
3014
+ -- \u2500\u2500 O defeito, medido \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
3015
+ --
3016
+ -- A tela "Encargos e honor\xE1rios" (RH \u2192 Custos) l\xEA o raz\xE3o do Financeiro e soma
3017
+ -- toda linha de conta a pagar que tenha conta cont\xE1bil. Toda. Medido no cluster
3018
+ -- de Pertinho do C\xE9u em 19/09/2026, o que ela chamava de custo de pessoal
3019
+ -- inclu\xEDa:
3020
+ --
3021
+ -- 6.2.1 Aluguel R$ 21.061,99
3022
+ -- 6.2.3 IPTU R$ 26.613,73
3023
+ -- 6.5.5 ICMS R$ 30.964,92
3024
+ -- 6.4.1 Equipamentos R$ 82.595,87
3025
+ -- 6.3.1 Energia/Light R$ 27.185,62
3026
+ --
3027
+ -- \u2014 ao lado das que s\xE3o, de fato, pessoal:
3028
+ --
3029
+ -- 5 DESPESAS COM PESSOAL (26 contas: Sal\xE1rio, FGTS, INSS, Comiss\xE3o,
3030
+ -- Hora Extra, Adiantamento, IRRF Folha, Vale Alimenta\xE7\xE3o, FREELANCER
3031
+ -- com oito filhas\u2026)
3032
+ --
3033
+ -- Um total que soma aluguel e ICMS sob o t\xEDtulo "custo de pessoal" n\xE3o est\xE1
3034
+ -- impreciso: est\xE1 errado, e erra para cima, que \xE9 o lado que faz algu\xE9m decidir
3035
+ -- contratar menos.
3036
+ --
3037
+ -- \u2500\u2500 Por que uma configura\xE7\xE3o, e n\xE3o uma regra \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
3038
+ --
3039
+ -- N\xE3o existe como o c\xF3digo saber qual ramo \xE9 pessoal. O plano de contas \xE9 uma
3040
+ -- DECLARA\xC7\xC3O da casa (ver \`040_o_demonstrativo_mostra_o_plano_inteiro\`), e cada
3041
+ -- casa numera o dela: aqui pessoal \xE9 o ramo \`5\`; o template que o SDK semeia
3042
+ -- para um tenant novo p\xF5e folha em \`4.03\`. Deduzir pelo NOME da conta seria
3043
+ -- adivinhar em portugu\xEAs \u2014 e \`8.2.4 Rescis\xE3o\` e \`8.2.6 Pro-labore\`, que tamb\xE9m
3044
+ -- s\xE3o gente, moram fora do ramo 5 neste mesmo plano, por escolha de quem montou.
3045
+ --
3046
+ -- Ent\xE3o a chave guarda a escolha, e a aus\xEAncia dela \xE9 uma aus\xEAncia honesta: sem
3047
+ -- nada escolhido a tela mostra TODAS as despesas classificadas e diz que \xE9 o que
3048
+ -- est\xE1 fazendo. Um padr\xE3o inventado ('5') faria a tela mentir com convic\xE7\xE3o no
3049
+ -- primeiro tenant cujo plano n\xE3o usa esse n\xFAmero.
3050
+ --
3051
+ -- \u2500\u2500 A forma \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\u2500\u2500
3052
+ --
3053
+ -- Um array de C\xD3DIGOS, n\xE3o de ids. O c\xF3digo \xE9 o que carrega a hierarquia: quem
3054
+ -- escolhe '5' escolhe o ramo inteiro \u2014 '5.4' e '5.4.1' entram com ele, e uma
3055
+ -- conta nova criada amanh\xE3 dentro do ramo entra sozinha. Com id seria preciso
3056
+ -- re-marcar a configura\xE7\xE3o a cada conta nova, e ningu\xE9m lembra.
3057
+ --
3058
+ -- \`tenant_setting_set\` cobra \`settings.manage\` e recusa chave n\xE3o registrada \u2014
3059
+ -- \xE9 por isso que este arquivo existe. Idempotente: \`seed_setting_keys\` faz
3060
+ -- upsert por chave.
3061
+
3062
+ SELECT app.seed_setting_keys($$[
3063
+ {
3064
+ "key": "workforce.payroll_account_codes", "type": "array", "default": null,
3065
+ "reader": "plugin:workforce",
3066
+ "description": "Os ramos do plano de contas que s\xE3o custo de pessoal, por c\xF3digo ('5', '8.2.4'). Um c\xF3digo traz toda a descend\xEAncia dele. Vazio ou nulo = a tela mostra todas as despesas classificadas e avisa que n\xE3o h\xE1 ramo escolhido."
3067
+ }
3068
+ ]$$::jsonb);
3069
+ `;
3070
+ var MIGRATION_007_A_PORTA_DE_SERVICO_DO_PONTO_FECHA_TAMBEM = `-- ---------------------------------------------------------------------------
3071
+ -- 007_a_porta_de_servico_do_ponto_fecha_tambem.sql
3072
+ --
3073
+ -- \u2500\u2500 o buraco, e por que ele estava invis\xEDvel \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
3074
+ --
3075
+ -- As fun\xE7\xF5es que este plugin cria em \`migration\` nasceram execut\xE1veis por
3076
+ -- PUBLIC \u2014 \xE9 o padr\xE3o do Postgres, e ningu\xE9m revogou. Elas s\xE3o
3077
+ -- \`SECURITY DEFINER\` e escrevem dados de QUALQUER inquilino: uma delas
3078
+ -- alcan\xE7\xE1vel pela chave an\xF4nima \xE9 a base inteira alcan\xE7\xE1vel pela chave
3079
+ -- an\xF4nima.
3080
+ --
3081
+ -- Isto n\xE3o aparecia porque a \`000_baseline\` falhava e levava junto os outros
3082
+ -- seis arquivos deste plugin \u2014 as fun\xE7\xF5es nunca chegavam a existir na bancada.
3083
+ -- Com a baseline consertada (#443) o passo inteiro passou a aplicar, e as duas
3084
+ -- su\xEDtes que guardam esta fronteira ficaram vermelhas na hora:
3085
+ --
3086
+ -- 090_migration_ledger/001 "no migration function is executable by anon
3087
+ -- or authenticated" \u2014 have 9, want 0
3088
+ -- crm/009 "nenhuma fun\xE7\xE3o do schema migration \xE9 cham\xE1vel
3089
+ -- por anon" \u2014 have 9, want 0
3090
+ --
3091
+ -- Ou seja: a regra sempre esteve escrita e medida; o que faltava era o plugin
3092
+ -- chegar ao banco para ser medido. \xC9 o argumento do #338 de novo \u2014 um plugin
3093
+ -- fora da corrente \xE9 um plugin cujas tabelas (e cujas portas) s\xE3o coincid\xEAncia.
3094
+ --
3095
+ -- \u2500\u2500 por extenso, e n\xE3o varrendo o cat\xE1logo \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
3096
+ --
3097
+ -- A tenta\xE7\xE3o \xE9 \`REVOKE\` num la\xE7o sobre \`pg_proc\`. J\xE1 foi tentado neste repo e
3098
+ -- derrubou 35 su\xEDtes: varrer revoga de fun\xE7\xF5es que outros passos criaram e
3099
+ -- precisam de execu\xE7\xE3o. Revogar em massa \xE9 f\xE1cil de escrever e imposs\xEDvel de
3100
+ -- revisar. A lista abaixo \xE9 a das fun\xE7\xF5es DESTE plugin, nomeada uma a uma.
3101
+ -- ---------------------------------------------------------------------------
3102
+
3103
+ DO $$
3104
+ DECLARE
3105
+ v_fn text;
3106
+ v_args text;
3107
+ v_n integer := 0;
3108
+ BEGIN
3109
+ -- Cada writer do de-para, com a assinatura que a corrente lhe deu. Nomear em
3110
+ -- vez de varrer \xE9 o que torna esta lista revis\xE1vel.
3111
+ FOR v_fn, v_args IN
3112
+ SELECT p.proname, pg_get_function_identity_arguments(p.oid)
3113
+ FROM pg_proc p
3114
+ JOIN pg_namespace n ON n.oid = p.pronamespace
3115
+ WHERE n.nspname = 'migration'
3116
+ AND (p.proname LIKE 'upsert_wf\\_%' OR p.proname IN ('_wf_person', '_point_punch_photo'))
3117
+ LOOP
3118
+ EXECUTE format('REVOKE ALL ON FUNCTION migration.%I(%s) FROM PUBLIC, anon, authenticated', v_fn, v_args);
3119
+ -- \`service_role\` fica: \xE9 quem a importa\xE7\xE3o usa, e \xE9 a \xFAnica identidade que
3120
+ -- tem raz\xE3o para atravessar inquilinos.
3121
+ EXECUTE format('GRANT EXECUTE ON FUNCTION migration.%I(%s) TO service_role', v_fn, v_args);
3122
+ v_n := v_n + 1;
3123
+ END LOOP;
3124
+
3125
+ RAISE NOTICE 'workforce/007: % fun\xE7\xE3o(\xF5es) de migration fechadas a anon e authenticated', v_n;
3126
+ END $$;
3127
+ `;
3128
+ var MIGRATIONS = [
3129
+ { id: "000_baseline", sql: MIGRATION_000_BASELINE },
3130
+ { id: "001_a_prova_da_batida_tem_bytes", sql: MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES },
3131
+ { id: "002_o_guarda_da_foto_olhava_o_nome_errado", sql: MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO },
3132
+ { id: "003_o_ponto_em_aberto_para_de_mentir_a_duracao", sql: MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO },
3133
+ { id: "004_a_jornada_implausivel_nao_soma", sql: MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA },
3134
+ { id: "005_a_ficha_trabalhista_tem_onde_morar", sql: MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR },
3135
+ { id: "006_o_custo_de_pessoal_e_um_ramo_do_plano", sql: MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO },
3136
+ { id: "007_a_porta_de_servico_do_ponto_fecha_tambem", sql: MIGRATION_007_A_PORTA_DE_SERVICO_DO_PONTO_FECHA_TAMBEM }
3137
+ ];
3138
+
3139
+ export { MIGRATIONS, MIGRATION_000_BASELINE, MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES, MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO, MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO, MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA, MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR, MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO, MIGRATION_007_A_PORTA_DE_SERVICO_DO_PONTO_FECHA_TAMBEM };
3140
+ //# sourceMappingURL=main.js.map
3141
+ //# sourceMappingURL=main.js.map