@fayz-ai/plugin-workforce 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +5 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +81 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.d.ts +98 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6416 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +12 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/settings/usePayrollAccounts.d.ts +14 -0
- package/dist/settings/usePayrollAccounts.d.ts.map +1 -0
- package/dist/store.d.ts +32 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +330 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/HrView.d.ts +5 -0
- package/dist/views/HrView.d.ts.map +1 -0
- package/dist/views/PayrollCostsView.d.ts +3 -0
- package/dist/views/PayrollCostsView.d.ts.map +1 -0
- package/dist/views/PeopleView.d.ts +8 -0
- package/dist/views/PeopleView.d.ts.map +1 -0
- package/dist/views/PeriodsView.d.ts +3 -0
- package/dist/views/PeriodsView.d.ts.map +1 -0
- package/dist/views/SchedulesView.d.ts +3 -0
- package/dist/views/SchedulesView.d.ts.map +1 -0
- package/dist/views/TemplatesView.d.ts +3 -0
- package/dist/views/TemplatesView.d.ts.map +1 -0
- package/dist/views/TimeEntriesView.d.ts +3 -0
- package/dist/views/TimeEntriesView.d.ts.map +1 -0
- package/dist/views/WorkforcePage.d.ts +18 -0
- package/dist/views/WorkforcePage.d.ts.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const MIGRATION_000_BASELINE = "-- O RH ganha ponto e escala.\n--\n-- D9 \u00B7 Gente & Jornada de trabalho \u00E9 o \u00FAltimo dom\u00EDnio inteiro do V1 sem NENHUMA\n-- contraparte no V2. O registro de features do app diz isso feature por feature:\n-- `sch.templates` P0 missing, `sch.calendar` P0 missing (\"Nenhum m\u00F3dulo do SDK \u00E9\n-- dono disso hoje \u2014 precisa de uma decis\u00E3o antes de c\u00F3digo\"), `sch.clock` P1\n-- missing (\"O que existe \u00E9 o hor\u00E1rio PREVISTO de trabalho. A batida realizada,\n-- com ajuste auditado, n\u00E3o existe\"), `sch.clock.adjust` P1 missing. O host\n-- previsto para os quatro \u00E9 o mesmo: **plugin-workforce**. Este arquivo \u00E9 a\n-- camada de dados dele.\n--\n-- \u2500\u2500 O que foi medido na origem (Pertinho do C\u00E9u, licen\u00E7a 8, 07/09/2026) \u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Da documenta\u00E7\u00E3o do V1 (`docs/v1-guide/restaurante/escalas/FLOW.md` e\n-- `docs/v1-guide/agenda/ponto-eletronico/FLOW.md`), tudo verificado na tela:\n--\n-- 325 registros de ponto no per\u00EDodo \u2014 e TODOS os 325 marcados\n-- \"Fora da escala\" (V1-043). A coluna existe justamente para cruzar\n-- ponto \u00D7 escala e nunca varia: o ponto est\u00E1 ativo em `PC Servi\u00E7os`\n-- enquanto a opera\u00E7\u00E3o e as escalas s\u00E3o de `Pertinho do C\u00E9u`.\n-- 20 batimentos EM ABERTO, incluindo 5 de 5 hoje e 8 de 16 ontem; um\n-- deles com entrada \u00E0s 23:29 e \"13h07 (parcial)\".\n-- R$ 47.747 o valor do per\u00EDodo \u2014 SUBESTIMADO, porque o V1 s\u00F3 calcula valor no\n-- fechamento do par, e 20 pares nunca fecharam.\n-- 103 profissionais habilitados para arrastar numa vaga de template.\n-- 7 templates de escala no tenant.\n--\n-- Tr\u00EAs dos n\u00FAmeros acima s\u00E3o sintoma do MESMO defeito de modelo, e \u00E9 ele que\n-- este arquivo existe para n\u00E3o repetir: no V1 a batida \u00E9 um par entrada/sa\u00EDda\n-- numa \u00FAnica linha, com o ajuste como colunas `*_adjusted_at` por cima. Um par\n-- que nunca fecha n\u00E3o tem dura\u00E7\u00E3o, n\u00E3o tem valor e n\u00E3o tem como ser conferido\n-- contra nada.\n--\n-- \u2500\u2500 Duas decis\u00F5es de arquitetura que o reposit\u00F3rio j\u00E1 tomou \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- 1. *\"Ponto \u00E9 raz\u00E3o append-only, como todo saldo \u2014 nunca uma coluna que algu\u00E9m\n-- edita.\"* (`docs/migration/how-to-port.json`, mv.sch.clock: *\"Batida \u00E9\n-- append-only e ajuste \u00E9 registro separado com autor e motivo \u2014 apagar\n-- batida \u00E9 o que a lei n\u00E3o permite.\"*)\n--\n-- Por isso `plg_workforce_time_entries` \u00E9 append-only de verdade \u2014 trigger\n-- que recusa UPDATE e DELETE, e as policies de UPDATE/DELETE derrubadas\n-- depois do scaffold \u2014 e o ajuste do V1 (`checkin_adjusted_at`,\n-- `checkout_adjusted_at`, `adjustment_reason`, `adjusted_by_*`) vira LINHA em\n-- `plg_workforce_time_adjustments`, com autor, motivo e carimbo. A pr\u00F3pria\n-- tela do V1 j\u00E1 enuncia a regra que o V2 passa a garantir no banco: *\"Os\n-- hor\u00E1rios reais dos batimentos s\u00E3o preservados.\"*\n--\n-- 2. *\"Escala de funcion\u00E1rio N\u00C3O mora na agenda \u2014 quem oferta hor\u00E1rio \u00E9 o RH, a\n-- agenda s\u00F3 consome.\"* (registro de features, dom\u00EDnio D3.)\n--\n-- Por isso a escala \u00E9 deste plugin, e a ponte com a agenda \u00E9 UMA coluna:\n-- `plg_workforce_schedule_assignments.schedule_row_id`, que aponta para a\n-- linha de `public.schedules` que a publica\u00E7\u00E3o materializa.\n--\n-- \u2500\u2500 O que este arquivo N\u00C3O cria, porque j\u00E1 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\n--\n-- O PREVISTO j\u00E1 est\u00E1 no V2. `public.schedules` (spine) tem exatamente a forma\n-- de uma oferta de hor\u00E1rio \u2014 `kind`, `assignee_id` \u2192 `public.people`,\n-- `day_of_week`, `specific_date`, `starts_at`, `ends_at`, `unit_id` \u2014 e\n-- `public.schedule_kinds` \u00E9 o registro de esp\u00E9cies dela (hoje: working_hours,\n-- exception, block, unclassified). Criar uma tabela de escala prevista aqui\n-- seria a segunda tabela de hor\u00E1rio do cluster, que diverge da primeira na\n-- pr\u00F3xima mudan\u00E7a. Em vez disso este arquivo **semeia a esp\u00E9cie `work_shift`** e\n-- usa `public.schedules`.\n--\n-- `public.people` tamb\u00E9m j\u00E1 resolve sozinha o pior defeito do modelo do V1: l\u00E1 o\n-- ponto aponta para `person_type ENUM('professional','employee')` + `person_id`,\n-- uma FK polim\u00F3rfica para duas tabelas de cadastro que a pr\u00F3pria tela mistura\n-- (\"A aba mistura Profissional e Funcion\u00E1rio no mesmo filtro e na mesma lista, e\n-- a diferen\u00E7a entre os dois cadastros n\u00E3o \u00E9 explicada em lugar nenhum\"). No V2\n-- os dois j\u00E1 s\u00E3o `public.people` com papel em `public.person_roles`, ent\u00E3o o par\n-- vira UMA FK `person_id uuid \u2192 public.people(id)`.\n--\n-- N\u00C3O porta `schedule_grids` / `schedule_grid_blocks`: apesar do nome s\u00E3o grade\n-- de disponibilidade de AGENDAMENTO, n\u00E3o escala de trabalho. Ficam registradas\n-- em `migration.excluded_tables` com o motivo, que \u00E9 mais barato que descobrir\n-- isso de novo daqui a seis meses.\n--\n-- \u2500\u2500 As tr\u00EAs 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\n--\n-- (a) FECHAMENTO DE COMPET\u00CANCIA. O V1 n\u00E3o tem nenhum. Qualquer ajuste pode\n-- mudar, em sil\u00EAncio, um m\u00EAs de horas j\u00E1 entregue ao contador e j\u00E1 pago \u2014\n-- e a \u00FAnica trava que existe \u00E9 de tela: `payable_invoice_id` preenchido\n-- esconde o bot\u00E3o \"Ajustar\". Uma trava de tela \u00E9 uma trava at\u00E9 a primeira\n-- chamada de API.\n-- \u2192 `plg_workforce_periods`, no MESMO padr\u00E3o que o financeiro j\u00E1 provou em\n-- `plugin-financial/src/migrations/006`: compet\u00EAncia \u00E9 o M\u00CAS (data no dia\n-- 1\u00BA, por constraint), \u00E9 do TENANT e n\u00E3o da unidade (um m\u00EAs meio fechado \u00E9\n-- a pior resposta poss\u00EDvel para quem precisa dizer que acabou), a escrita\n-- \u00E9 s\u00F3 por RPC (`workforce_period_close` / `workforce_period_reopen`, sem\n-- policy de INSERT/UPDATE \u2014 uma policy de UPDATE seria a porta dos fundos\n-- da trava), reabrir exige motivo, e a permiss\u00E3o de fechar \u00E9 pr\u00F3pria\n-- (`workforce.close_period`): quem bate ponto n\u00E3o \u00E9 quem fecha o m\u00EAs.\n-- A trava vira trigger sobre a batida E sobre o ajuste, e o ajuste ganha\n-- a segunda trava do V1, agora no banco: batida j\u00E1 vinculada a um t\u00EDtulo\n-- a pagar n\u00E3o aceita ajuste.\n--\n-- (b) JORNADA PREVISTA \u00D7 REALIZADA. No V1 elas nunca se encontram: a coluna\n-- \"Escala\" da lista de ponto existe para isso e marcou 325 de 325 registros\n-- como \"Fora da escala\".\n-- \u2192 O PREVISTO mora em `public.schedules` com `kind = 'work_shift'` (n\u00E3o em\n-- tabela nova \u2014 ver acima); o REALIZADO mora em\n-- `plg_workforce_time_entries`; e o cruzamento \u00E9 a view\n-- `public.v_workforce_day`, um FULL OUTER JOIN de prop\u00F3sito, para que as\n-- duas assimetrias apare\u00E7am em vez de sumirem: previsto sem realizado \u00E9\n-- FALTA, realizado sem previsto \u00E9 FORA DA ESCALA. A publica\u00E7\u00E3o da escala\n-- materializa a linha de `public.schedules` e guarda o id em\n-- `plg_workforce_schedule_assignments.schedule_row_id`, que \u00E9 o que torna\n-- o cruzamento uma chave e n\u00E3o uma heur\u00EDstica de nome e hor\u00E1rio.\n--\n-- (c) INTERVALO / ALMO\u00C7O. O V1 tem UM par entrada/sa\u00EDda por ciclo. Quem sai para\n-- almo\u00E7ar e volta ou n\u00E3o registra o intervalo, ou bate a sa\u00EDda e a entrada e\n-- o dia vira dois registros que ningu\u00E9m soma. Num restaurante com turno\n-- partido isso \u00E9 a jornada inteira.\n-- \u2192 A batida deixa de ser um PAR numa linha e passa a ser um EVENTO:\n-- `kind \u2208 (clock_in, break_start, break_end, clock_out)`, N por dia,\n-- agrupados por `(person_id, work_date)` e ordenados por `seq`. A mesma\n-- decis\u00E3o que satisfaz o append-only resolve o intervalo de gra\u00E7a, e ainda\n-- responde \"quais pontos est\u00E3o em aberto?\" sem coluna derivada: em aberto\n-- \u00E9 o dia cujos eventos de abertura (clock_in, break_end) n\u00E3o empatam com\n-- os de fechamento (clock_out, break_start).\n-- `work_date` \u00E9 coluna, e n\u00E3o `punched_at::date`, porque o V1 j\u00E1 convive\n-- com o turno que cruza a meia-noite \u2014 a pr\u00F3pria caixa de ajuste avisa:\n-- *\"\u00FAtil quando o funcion\u00E1rio esqueceu de bater a sa\u00EDda e o registro\n-- cruzou para o dia seguinte\"*. A jornada da noite de s\u00E1bado \u00E9 de s\u00E1bado.\n--\n-- \u2500\u2500 Uma decis\u00E3o a mais, tomada na fronteira da migra\u00E7\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- No molde do V1, titular vs stand-by \u00E9 POSICIONAL: s\u00E3o titulares os primeiros\n-- `required_count` de `schedule_template_assignments` por `priority`, e o resto\n-- \u00E9 reserva. Ningu\u00E9m grava isso; \u00E9 uma regra que vive no c\u00F3digo da tela. Na\n-- inst\u00E2ncia o V1 j\u00E1 tem `role` como coluna de verdade. Aqui o molde tamb\u00E9m tem:\n-- `plg_workforce_schedule_template_assignments.role` \u00E9 coluna, e o writer da\n-- migra\u00E7\u00E3o CALCULA a posi\u00E7\u00E3o uma vez, contra o `required_count` da fun\u00E7\u00E3o,\n-- em vez de deixar a regra escondida em cada leitor que vier depois.\n--\n-- Idempotente. Nada aqui apaga dado.\n\n-- A RPC de fechamento consulta a view de cruzamento, que nasce tr\u00EAs se\u00E7\u00F5es\n-- depois dela \u2014 as duas moram onde o assunto est\u00E1, n\u00E3o onde o carregador\n-- preferiria. Sess\u00E3o, e \u00E9 o que os baselines gerados do cluster j\u00E1 fazem.\nSET check_function_bodies = false;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 0. O plugin existe: as permiss\u00F5es\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- O scaffold desenha as policies em cima de `<plugin>.read/create/edit/delete`\n-- e `<plugin>.read_all`. Sem essas chaves no cat\u00E1logo, a policy compila e nega\n-- tudo para todo mundo \u2014 uma tabela protegida por n\u00E3o existir para ningu\u00E9m.\n--\n-- Duas chaves s\u00E3o separadas de prop\u00F3sito, e as duas s\u00E3o segrega\u00E7\u00E3o de fun\u00E7\u00E3o:\n-- `workforce.adjust_time` porque corrigir a batida de outra pessoa \u00E9 outra\n-- ordem de decis\u00E3o que editar uma escala, e `workforce.close_period` porque\n-- quem aponta n\u00E3o \u00E9 quem fecha o m\u00EAs.\nSELECT app.seed_permissions('[\n {\"key\": \"workforce.read\", \"plugin\": \"workforce\", \"description\": \"Ver escala, ponto e compet\u00EAncia.\"},\n {\"key\": \"workforce.create\", \"plugin\": \"workforce\", \"description\": \"Criar escala, template e batida.\"},\n {\"key\": \"workforce.edit\", \"plugin\": \"workforce\", \"description\": \"Editar escala e template.\"},\n {\"key\": \"workforce.delete\", \"plugin\": \"workforce\", \"description\": \"Excluir escala e template.\"},\n {\"key\": \"workforce.manage\", \"plugin\": \"workforce\", \"description\": \"Administrar o m\u00F3dulo de jornada.\"},\n {\"key\": \"workforce.read_all\", \"plugin\": \"workforce\", \"description\": \"Ver a jornada de todo mundo, e n\u00E3o s\u00F3 a pr\u00F3pria.\"},\n {\"key\": \"workforce.adjust_time\", \"plugin\": \"workforce\", \"description\": \"Ajustar batida de ponto. Separada de workforce.edit: corrigir a hora de outra pessoa \u00E9 prova, n\u00E3o edi\u00E7\u00E3o.\"},\n {\"key\": \"workforce.close_period\", \"plugin\": \"workforce\", \"description\": \"Fechar e reabrir a compet\u00EAncia de jornada. Separada de workforce.edit: quem aponta n\u00E3o \u00E9 quem fecha.\"}\n]'::jsonb);\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 1. O PREVISTO j\u00E1 existe \u2014 s\u00F3 falta a esp\u00E9cie\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- `working_hours` \u00E9 a semana recorrente do PROFISSIONAL, para a agenda saber que\n-- um hor\u00E1rio pode existir. `work_shift` \u00E9 outra coisa: \u00E9 o turno de trabalho que\n-- o RH ofertou para uma pessoa num dia, e \u00E9 contra ele que o ponto \u00E9 conferido.\n-- Reaproveitar `working_hours` misturaria \"posso ser agendado\" com \"estou\n-- escalado\", que \u00E9 justamente a confus\u00E3o que o D3 mandou n\u00E3o fazer.\nINSERT INTO public.schedule_kinds (kind, plugin, description, status)\nVALUES ('work_shift', 'workforce',\n 'O turno de trabalho que o RH escalou para uma pessoa \u2014 o PREVISTO contra o qual o ponto \u00E9 conferido. Distinto de working_hours, que diz apenas quando um hor\u00E1rio PODE existir na agenda.',\n 'declared')\nON CONFLICT (kind) DO UPDATE\n SET plugin = EXCLUDED.plugin, description = EXCLUDED.description, updated_at = now();\n\n-- `public.schedules` foi scaffoldada em modo `shadow` (065): as policies de\n-- autoriza\u00E7\u00E3o existem, mas as anteriores nunca foram derrubadas, e RLS soma\n-- policies em OR \u2014 uma policy legada sobrevivente alarga tudo silenciosamente.\n-- A partir do momento em que a escala do RH mora aqui dentro, sombra n\u00E3o serve:\n-- `scaffold_set_mode` reemite com os MESMOS resource_type/plugin j\u00E1 registrados\n-- (`agenda.schedule` / `agenda`) e derruba o que sobrou. N\u00E3o \u00E9 `scaffold_table`:\n-- repetir o scaffold aqui reclassificaria uma tabela do spine para dentro deste\n-- plugin, e a agenda perderia a pr\u00F3pria mesa.\nDO $$\nBEGIN\n IF to_regclass('public.schedules') IS NOT NULL\n AND EXISTS (SELECT 1 FROM app.scaffold_registry\n WHERE table_schema = 'public' AND table_name = 'schedules' AND mode = 'shadow') THEN\n PERFORM app.scaffold_set_mode('public.schedules'::regclass, 'enforce');\n END IF;\nEND $$;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 2. O REALIZADO: a batida, append-only\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_time_entries (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n -- UMA fk, onde o V1 tem person_type + person_id. `public.people` j\u00E1 \u00E9 o\n -- cadastro \u00FAnico; profissional e funcion\u00E1rio s\u00E3o pap\u00E9is em person_roles.\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,\n -- O nome COMO ESTAVA na hora da batida. N\u00E3o \u00E9 desnormaliza\u00E7\u00E3o por pregui\u00E7a: a\n -- folha de ponto \u00E9 prova, e prova n\u00E3o pode mudar porque algu\u00E9m corrigiu um\n -- cadastro dois anos depois.\n person_name text,\n -- O evento, n\u00E3o o par. \u00C9 esta coluna que resolve o intervalo (c): o almo\u00E7o \u00E9\n -- break_start + break_end, e o dia aceita quantos pares de intervalo houver.\n kind text NOT NULL\n CHECK (kind IN ('clock_in', 'break_start', 'break_end', 'clock_out')),\n punched_at timestamptz NOT NULL,\n -- O DIA DA JORNADA, que n\u00E3o \u00E9 `punched_at::date`. O turno da noite de s\u00E1bado\n -- que sai \u00E0s 02:10 \u00E9 jornada de s\u00E1bado; o V1 j\u00E1 convive com isso e a pr\u00F3pria\n -- caixa de ajuste dele avisa que o registro \"cruzou para o dia seguinte\".\n work_date date NOT NULL,\n -- Ordena os eventos do dia quando dois caem no mesmo instante (totem com\n -- clique duplo, importa\u00E7\u00E3o em lote). Sem ele o \"\u00FAltimo evento do dia\" \u00E9\n -- indeterminado, e \u00E9 ele que diz se o ponto est\u00E1 em aberto.\n seq integer NOT NULL DEFAULT 1,\n origin text NOT NULL DEFAULT 'totem'\n CHECK (origin IN ('totem', 'device', 'manual', 'import', 'migration')),\n -- A prova que o totem produz: foto, coordenada com precis\u00E3o, semelhan\u00E7a\n -- facial, IP e aparelho. Fica em jsonb e n\u00E3o em dez colunas porque \u00E9 um\n -- conjunto de evid\u00EAncia de UM canal de captura \u2014 o totem com AWS Rekognition\n -- do V1 \u2014 e o pr\u00F3ximo canal (crach\u00E1, biometria local) traz outro conjunto.\n -- O que n\u00E3o pode \u00E9 perder: no V1 s\u00E3o `photo_path`, `lat`, `lng`, `accuracy_m`,\n -- `ip`, `user_agent` e `face_score`, medidos em campo como\n -- \"-22.89342, -43.39140 \u00B7 \u00B1 14m\" e \"Match facial 100.0%\".\n evidence jsonb NOT NULL DEFAULT '{}'::jsonb,\n -- O turno que esta batida cumpre, quando h\u00E1 um. Nulo \u00E9 uma resposta leg\u00EDtima\n -- e frequente \u2014 \u00E9 literalmente 325 de 325 no tenant medido \u2014 e o V2 prefere\n -- o nulo honesto a um v\u00EDnculo inventado.\n assignment_id uuid,\n -- Onde esta hora custa. O turno traz o padr\u00E3o (ver plg_workforce_schedule_shifts);\n -- a batida carrega o que de fato foi classificado, porque a hora do cozinheiro\n -- e a do gar\u00E7om caem em centros de custo diferentes no mesmo dia.\n -- Sem FK inline: as tr\u00EAs colunas abaixo apontam para tabelas de OUTRO plugin,\n -- e a jornada tem de poder ser instalada num cluster que n\u00E3o ligou o\n -- financeiro. A FK \u00E9 declarada no fim do arquivo, condicionada \u00E0 exist\u00EAncia do\n -- destino \u2014 presente vira integridade, ausente vira s\u00F3 um uuid guardado.\n chart_account_id uuid,\n cost_center_id uuid,\n -- O `payable_invoice_id` do V1. No V1 ele \u00E9 uma trava DE TELA (preenchido,\n -- o bot\u00E3o \"Ajustar\" some); aqui ele \u00E9 uma trava de banco, pela trigger da\n -- se\u00E7\u00E3o 4. Hora j\u00E1 paga n\u00E3o se corrige sem reabrir a compet\u00EAncia.\n payable_invoice_id uuid,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- A mesma pessoa n\u00E3o bate o mesmo evento duas vezes no mesmo instante. \u00C9 o\n -- que torna o replay da migra\u00E7\u00E3o e o clique duplo no totem inofensivos.\n UNIQUE (tenant_id, person_id, work_date, kind, punched_at, seq)\n);\n\nCOMMENT ON TABLE public.plg_workforce_time_entries IS\n 'A batida, append-only: um EVENTO (entrada, in\u00EDcio/fim de intervalo, sa\u00EDda), nunca um par edit\u00E1vel. '\n 'UPDATE e DELETE s\u00E3o recusados por trigger; a corre\u00E7\u00E3o \u00E9 uma linha em plg_workforce_time_adjustments.';\n\n-- \"O que foi batido neste per\u00EDodo, nesta unidade?\" \u2014 a pergunta da aba Registros\n-- de Ponto, que o V1 responde com filtros De/At\u00E9/Unidade/Pessoa.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_period_idx\n ON public.plg_workforce_time_entries (tenant_id, unit_id, work_date, punched_at);\n\n-- \"Quais pontos est\u00E3o em aberto?\" \u2014 20 no tenant medido. Em aberto n\u00E3o \u00E9 coluna\n-- (seria estado derivado numa tabela append-only): \u00E9 o dia em que os eventos de\n-- ABERTURA n\u00E3o empatam com os de fechamento. Este \u00EDndice serve a metade que a\n-- view varre, e responde sozinho \"quem entrou hoje e onde\".\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_open_idx\n ON public.plg_workforce_time_entries (tenant_id, person_id, work_date, seq)\n WHERE kind IN ('clock_in', 'break_end');\n\n-- \"O que esta pessoa bateu?\" \u2014 a folha individual, e o que a folha de pagamento\n-- soma no fim do m\u00EAs.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_entries_person_idx\n ON public.plg_workforce_time_entries (tenant_id, person_id, work_date DESC);\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_updated_at ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_updated_at\n BEFORE UPDATE ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT ON public.plg_workforce_time_entries TO authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_entries TO service_role;\n\n-- O scaffold \u00E9 quem sabe desenhar a pol\u00EDtica deste cluster (tenant, unidade,\n-- permiss\u00E3o do plugin, escotilhas de compartilhamento). Escrever a policy \u00E0 m\u00E3o\n-- criaria a segunda regra de autoriza\u00E7\u00E3o da jornada, que diverge da primeira na\n-- pr\u00F3xima mudan\u00E7a do scaffold.\n--\n-- `p_owner_scoping = false`: a batida n\u00E3o tem dono no sentido de \"criada por\n-- mim\". Quem enxerga ponto de quem \u00E9 `workforce.read` por unidade.\nSELECT app.scaffold_table('public.plg_workforce_time_entries'::regclass,\n 'workforce.time_entry', 'workforce', false, 'enforce');\n\n-- \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\n--\n-- O scaffold acabou de criar policies de UPDATE e DELETE, porque \u00E9 isso que ele\n-- faz em toda tabela. Aqui elas n\u00E3o deveriam existir: apagar batida \u00E9 o que a\n-- lei n\u00E3o permite. As duas defesas s\u00E3o deliberadamente redundantes \u2014 a policy\n-- some para quem entra por `authenticated`, e a trigger pega tamb\u00E9m service_role\n-- e qualquer SECURITY DEFINER esquecido.\nDROP POLICY IF EXISTS plg_workforce_time_entries_authz_update ON public.plg_workforce_time_entries;\nDROP POLICY IF EXISTS plg_workforce_time_entries_authz_delete ON public.plg_workforce_time_entries;\n\nCREATE OR REPLACE FUNCTION public.plg_workforce_time_entries_append_only()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nBEGIN\n -- A escotilha \u00E9 a mesma que o resto do cluster usa: durante uma onda de\n -- migra\u00E7\u00E3o o motor marca a sess\u00E3o, e s\u00F3 ela pode reescrever a linha que ela\n -- mesma acabou de trazer. Fora disso n\u00E3o h\u00E1 caminho.\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN CASE TG_OP WHEN 'DELETE' THEN OLD ELSE NEW END;\n END IF;\n RAISE EXCEPTION 'plg_workforce_time_entries \u00E9 append-only (% recusado) \u2014 corrija pelo ajuste, que \u00E9 uma linha com autor e motivo', TG_OP\n USING ERRCODE = '42501',\n HINT = 'INSERT em plg_workforce_time_adjustments (action correct|insert|void) exige workforce.adjust_time.';\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_append_only ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_append_only\n BEFORE UPDATE OR DELETE ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.plg_workforce_time_entries_append_only();\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 3. O AJUSTE \u00E9 uma linha, n\u00E3o uma coluna\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- O V1 guarda o ajuste como quatro colunas na pr\u00F3pria batida\n-- (`checkin_adjusted_at`, `checkout_adjusted_at`, `adjustment_reason`,\n-- `adjusted_by_*`) \u2014 o que j\u00E1 \u00E9 melhor que sobrescrever, e \u00E9 a parte boa do\n-- modelo dele: *\"Os hor\u00E1rios reais dos batimentos s\u00E3o preservados.\"* O que ele\n-- n\u00E3o tem \u00E9 HIST\u00D3RICO: o segundo ajuste apaga o primeiro, e ningu\u00E9m consegue\n-- dizer quantas vezes aquela hora mudou nem por quem.\n--\n-- Tr\u00EAs a\u00E7\u00F5es, porque o V1 faz as tr\u00EAs e chama todas de \"ajustar\":\n-- correct \u2014 a hora \u00E9 outra (o caso comum)\n-- insert \u2014 faltou a batida (o \"esqueceu de bater a sa\u00EDda\" do V1; a batida\n-- nova entra em plg_workforce_time_entries com origin 'manual' e\n-- esta linha \u00E9 quem carrega o autor e o motivo dela)\n-- void \u2014 esta batida n\u00E3o conta (o clique duplo, o totem testado). N\u00E3o \u00E9\n-- DELETE: a linha fica, marcada, porque sumir com prova \u00E9 o que o\n-- append-only existe para impedir.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_time_adjustments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n time_entry_id uuid NOT NULL\n REFERENCES public.plg_workforce_time_entries(id) ON DELETE CASCADE,\n action text NOT NULL CHECK (action IN ('correct', 'insert', 'void')),\n -- A hora que passa a valer. Obrigat\u00F3ria em correct/insert e sem sentido em\n -- void \u2014 a constraint diz isso em vez de deixar para a tela lembrar.\n effective_at timestamptz,\n -- No V1 o motivo \u00E9 OPCIONAL (\"Motivo (opcional)\"), e \u00E9 por isso que ningu\u00E9m\n -- sabe por que 20 pontos foram mexidos. Aqui \u00E9 NOT NULL: um ajuste sem motivo\n -- registrado \u00E9 indistingu\u00EDvel de uma fraude bem-educada.\n reason text NOT NULL,\n adjusted_by_user_id uuid,\n -- O nome de quem ajustou, congelado \u2014 mesma raz\u00E3o de `person_name`.\n adjusted_by_name text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_time_adjustments_effective_check\n CHECK ((action = 'void' AND effective_at IS NULL) OR (action <> 'void' AND effective_at IS NOT NULL)),\n CONSTRAINT plg_workforce_time_adjustments_reason_check\n CHECK (btrim(reason) <> '')\n);\n\nCOMMENT ON TABLE public.plg_workforce_time_adjustments IS\n 'O ajuste de ponto como LINHA, com autor e motivo obrigat\u00F3rio. O V1 guarda isso em colunas da pr\u00F3pria batida e perde o hist\u00F3rico no segundo ajuste; aqui cada corre\u00E7\u00E3o fica, e a hora que vale \u00E9 a da \u00FAltima n\u00E3o anulada.';\n\n-- \"Qual a hora que vale nesta batida?\" \u2014 a view da se\u00E7\u00E3o 7 pergunta isso uma vez\n-- por batida, e \u00E9 o caminho quente da folha de ponto inteira.\nCREATE INDEX IF NOT EXISTS plg_workforce_time_adjustments_entry_idx\n ON public.plg_workforce_time_adjustments (time_entry_id, created_at DESC);\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_updated_at ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_updated_at\n BEFORE UPDATE ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_time_adjustments TO authenticated, service_role;\n\nSELECT app.scaffold_table('public.plg_workforce_time_adjustments'::regclass,\n 'workforce.time_adjustment', 'workforce', false, 'enforce');\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 4. A COMPET\u00CANCIA (lacuna a)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- Mesmo desenho de `plg_financial_periods` (plugin-financial/006), de\n-- prop\u00F3sito: a empresa fecha UM m\u00EAs, e ter dois calend\u00E1rios de fechamento com\n-- regras diferentes \u2014 um do dinheiro e outro das horas \u2014 \u00E9 como n\u00E3o ter\n-- fechamento nenhum, porque a pergunta \"fevereiro est\u00E1 fechado?\" passa a ter\n-- duas respostas.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_periods (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- A compet\u00EAncia \u00E9 do TENANT, n\u00E3o da unidade: fechar o m\u00EAs \u00E9 um ato de\n -- empresa, e um m\u00EAs meio fechado \u00E9 a pior resposta para quem precisa dizer ao\n -- contador que acabou. A coluna existe porque o scaffold precisa dela.\n unit_id uuid REFERENCES app.units(id),\n -- Sempre o dia 1\u00BA, por constraint e n\u00E3o por conven\u00E7\u00E3o de quem escreve.\n competence_month date NOT NULL,\n status text NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')),\n closed_at timestamptz,\n closed_by_user_id uuid,\n reopened_at timestamptz,\n reopened_by_user_id uuid,\n -- Reabrir sem dizer por qu\u00EA \u00E9 como n\u00E3o ter fechado. Obrigat\u00F3rio na RPC; a\n -- coluna aceita nulo porque um per\u00EDodo nunca reaberto n\u00E3o tem um.\n reopen_reason text,\n -- O que o fechamento virou no financeiro, quando virou. A folha \u00E9 uma conta a\n -- pagar como outra qualquer, e a classifica\u00E7\u00E3o dela mora aqui porque \u00E9 o\n -- fechamento \u2014 n\u00E3o a batida \u2014 que produz o lan\u00E7amento.\n payable_invoice_id uuid,\n chart_account_id uuid,\n cost_center_id uuid,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_periods_first_of_month_check\n CHECK (competence_month = date_trunc('month', competence_month)::date),\n CONSTRAINT plg_workforce_periods_tenant_month_key UNIQUE (tenant_id, competence_month)\n);\n\nCOMMENT ON TABLE public.plg_workforce_periods IS\n 'Compet\u00EAncia mensal da jornada e seu estado. Fechada = nenhuma batida nem ajuste entra com data naquele m\u00EAs. Escrita s\u00F3 por workforce_period_close/reopen.';\n\n-- A trava consulta \"este m\u00EAs est\u00E1 fechado?\" a cada batida: \u00EDndice parcial.\nCREATE INDEX IF NOT EXISTS plg_workforce_periods_closed_idx\n ON public.plg_workforce_periods (tenant_id, competence_month) WHERE status = 'closed';\n\nDROP TRIGGER IF EXISTS plg_workforce_periods_updated_at ON public.plg_workforce_periods;\nCREATE TRIGGER plg_workforce_periods_updated_at\n BEFORE UPDATE ON public.plg_workforce_periods\n FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nGRANT SELECT ON public.plg_workforce_periods TO authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_workforce_periods TO service_role;\n\nSELECT app.scaffold_table('public.plg_workforce_periods'::regclass,\n 'workforce.period', 'workforce', false, 'enforce');\n\n-- ESCREVER \u00C9 S\u00D3 PELA RPC. Fechar e reabrir s\u00E3o ATOS, n\u00E3o edi\u00E7\u00F5es de linha, e o\n-- ato \u00E9 onde a permiss\u00E3o e o registro de QUEM moram. Uma policy de UPDATE aqui\n-- seria a porta dos fundos da trava \u2014 \u00E9 a mesma decis\u00E3o do financeiro/006.\nDROP POLICY IF EXISTS plg_workforce_periods_authz_insert ON public.plg_workforce_periods;\nDROP POLICY IF EXISTS plg_workforce_periods_authz_update ON public.plg_workforce_periods;\nDROP POLICY IF EXISTS plg_workforce_periods_authz_delete ON public.plg_workforce_periods;\n\nCREATE OR REPLACE FUNCTION public.plg_workforce_period_is_closed(p_tenant uuid, p_date date)\n RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT EXISTS (\n SELECT 1 FROM public.plg_workforce_periods p\n WHERE p.tenant_id = p_tenant\n AND p.status = 'closed'\n AND p.competence_month = date_trunc('month', p_date)::date);\n$$;\n\nCOMMENT ON FUNCTION public.plg_workforce_period_is_closed(uuid, date) IS\n 'O m\u00EAs desta data est\u00E1 fechado para a jornada deste tenant?';\n\n-- \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\nCREATE OR REPLACE FUNCTION public.trg_workforce_refuse_closed_period()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_date date;\nBEGIN\n -- Uma onda de migra\u00E7\u00E3o traz o passado inteiro, e o passado inteiro est\u00E1 em\n -- meses que o V1 j\u00E1 tratou como encerrados. Fechar por cima do que ainda est\u00E1\n -- chegando seria fechar antes de existir.\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN NEW;\n END IF;\n\n -- A data que conta \u00E9 a da JORNADA, n\u00E3o a de cria\u00E7\u00E3o da linha.\n IF TG_TABLE_NAME = 'plg_workforce_time_entries' THEN\n v_date := NEW.work_date;\n ELSE\n SELECT e.work_date INTO v_date\n FROM public.plg_workforce_time_entries e WHERE e.id = NEW.time_entry_id;\n END IF;\n\n IF v_date IS NOT NULL AND public.plg_workforce_period_is_closed(NEW.tenant_id, v_date) THEN\n RAISE EXCEPTION 'a compet\u00EAncia de % est\u00E1 fechada \u2014 reabra o per\u00EDodo para lan\u00E7ar nela', to_char(v_date, 'MM/YYYY')\n USING ERRCODE = '23514',\n HINT = 'workforce_period_reopen(date_trunc(''month'', data)::date, ''motivo'') exige workforce.close_period.';\n END IF;\n\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_entries_refuse_closed_period ON public.plg_workforce_time_entries;\nCREATE TRIGGER plg_workforce_time_entries_refuse_closed_period\n BEFORE INSERT ON public.plg_workforce_time_entries\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_closed_period ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_refuse_closed_period\n BEFORE INSERT OR UPDATE ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_closed_period();\n\n-- \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\n--\n-- No V1, batida com `payable_invoice_id` preenchido n\u00E3o pode ser ajustada \u2014 e a\n-- regra vive no componente que esconde o bot\u00E3o. Aqui ela vive onde n\u00E3o d\u00E1 para\n-- contornar. A linha migrada \u00E9 coberta pela mesma trava: o V1 aponta para uma\n-- tabela de t\u00EDtulos cuja contraparte no V2 n\u00E3o \u00E9 a mesma coisa (ver a nota do\n-- de-para), ent\u00E3o a refer\u00EAncia crua fica em `metadata.v1.payable_invoice_id` \u2014 e\n-- basta ela existir para a hora estar travada.\nCREATE OR REPLACE FUNCTION public.trg_workforce_refuse_settled_adjustment()\n RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_entry public.plg_workforce_time_entries%ROWTYPE;\nBEGIN\n IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN\n RETURN NEW;\n END IF;\n SELECT * INTO v_entry FROM public.plg_workforce_time_entries WHERE id = NEW.time_entry_id;\n IF v_entry.payable_invoice_id IS NOT NULL\n OR (v_entry.metadata #>> '{v1,payable_invoice_id}') IS NOT NULL THEN\n RAISE EXCEPTION 'esta batida j\u00E1 virou t\u00EDtulo a pagar \u2014 a hora paga n\u00E3o se corrige sem estornar o t\u00EDtulo'\n USING ERRCODE = '23514';\n END IF;\n IF NOT app.has_permission('workforce.adjust_time', v_entry.unit_id) THEN\n RAISE EXCEPTION 'ajustar ponto exige workforce.adjust_time' USING ERRCODE = '42501';\n END IF;\n RETURN NEW;\nEND $$;\n\nDROP TRIGGER IF EXISTS plg_workforce_time_adjustments_refuse_settled ON public.plg_workforce_time_adjustments;\nCREATE TRIGGER plg_workforce_time_adjustments_refuse_settled\n BEFORE INSERT ON public.plg_workforce_time_adjustments\n FOR EACH ROW EXECUTE FUNCTION public.trg_workforce_refuse_settled_adjustment();\n\n-- \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\nCREATE OR REPLACE FUNCTION public.workforce_period_close(p_month date, p_notes text DEFAULT NULL)\n RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_open bigint;\n v_row public.plg_workforce_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'workforce_period_close: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('workforce.close_period', NULL) THEN\n RAISE EXCEPTION 'workforce_period_close: exige workforce.close_period' USING ERRCODE = '42501';\n END IF;\n IF v_month > date_trunc('month', current_date)::date THEN\n RAISE EXCEPTION 'workforce_period_close: % ainda n\u00E3o terminou', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = '22023';\n END IF;\n\n -- Ponto em aberto \u00E9 jornada que ainda vai mudar. Fechar por cima produz um\n -- total de horas que muda depois de fechado, que \u00E9 exatamente o que a trava\n -- existe para impedir \u2014 e \u00E9 o defeito medido no V1: 20 batimentos em aberto\n -- fazem o valor do per\u00EDodo (R$ 47.747) ser um n\u00FAmero menor que o real.\n SELECT count(*) INTO v_open\n FROM public.v_workforce_day d\n WHERE d.tenant_id = v_tenant\n AND d.is_open\n AND date_trunc('month', d.work_date)::date <= v_month;\n IF v_open > 0 THEN\n 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')\n USING ERRCODE = '23514',\n HINT = 'SELECT * FROM public.v_workforce_day WHERE is_open;';\n END IF;\n\n INSERT INTO public.plg_workforce_periods (tenant_id, competence_month, status, closed_at, closed_by_user_id, notes)\n VALUES (v_tenant, v_month, 'closed', now(), auth.uid(), p_notes)\n ON CONFLICT (tenant_id, competence_month) DO UPDATE\n SET status = 'closed', closed_at = now(), closed_by_user_id = auth.uid(),\n notes = coalesce(EXCLUDED.notes, public.plg_workforce_periods.notes), updated_at = now()\n RETURNING * INTO v_row;\n\n RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,\n 'status', 'closed', 'closed_at', v_row.closed_at);\nEND $$;\n\nCREATE OR REPLACE FUNCTION public.workforce_period_reopen(p_month date, p_reason text)\n RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_month date := date_trunc('month', p_month)::date;\n v_row public.plg_workforce_periods%ROWTYPE;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: sem tenant na sess\u00E3o' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('workforce.close_period', NULL) THEN\n RAISE EXCEPTION 'workforce_period_reopen: exige workforce.close_period' USING ERRCODE = '42501';\n END IF;\n IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: o motivo \u00E9 obrigat\u00F3rio' USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.plg_workforce_periods SET\n status = 'open', reopened_at = now(), reopened_by_user_id = auth.uid(),\n reopen_reason = p_reason, updated_at = now()\n WHERE tenant_id = v_tenant AND competence_month = v_month\n RETURNING * INTO v_row;\n\n IF v_row.id IS NULL THEN\n RAISE EXCEPTION 'workforce_period_reopen: % nunca foi fechado', to_char(v_month, 'MM/YYYY')\n USING ERRCODE = 'P0002';\n END IF;\n\n RETURN jsonb_build_object('period_id', v_row.id, 'competence_month', v_month,\n 'status', 'open', 'reopened_at', v_row.reopened_at, 'reason', p_reason);\nEND $$;\n\nREVOKE ALL ON FUNCTION public.workforce_period_close(date, text) FROM PUBLIC;\nREVOKE ALL ON FUNCTION public.workforce_period_reopen(date, text) FROM PUBLIC;\nGRANT EXECUTE ON FUNCTION public.workforce_period_close(date, text) TO authenticated;\nGRANT EXECUTE ON FUNCTION public.workforce_period_reopen(date, text) TO authenticated;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 5. A ESCALA: o molde\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_templates (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n -- No V1 o template \u00E9 POR UNIDADE (o editor abre com um seletor de Unidade), e\n -- aqui tamb\u00E9m: \"Almo\u00E7o Seg. a Sex.\" da matriz n\u00E3o \u00E9 o da filial.\n unit_id uuid REFERENCES app.units(id),\n name text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCOMMENT ON TABLE public.plg_workforce_schedule_templates IS\n 'O molde da escala. Pr\u00E9-requisito duro no V1: sem template n\u00E3o se cria escala nenhuma \u2014 \"Selecione um template de escala acima para come\u00E7ar.\"';\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_shifts (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n template_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,\n label text NOT NULL,\n start_time time NOT NULL,\n end_time time NOT NULL,\n -- 0 = domingo, como `extract(dow)` e como `public.schedules.day_of_week`.\n -- A constraint existe porque um 7 solto num array \u00E9 um turno que nunca\n -- acontece e que ningu\u00E9m percebe faltando.\n weekdays smallint[] NOT NULL DEFAULT '{}'::smallint[],\n -- Turno que vira o dia (16:00\u201302:00) \u00E9 normal em restaurante, e \u00E9 a raz\u00E3o de\n -- `work_date` ser coluna na batida. Guardar a travessia aqui evita que cada\n -- leitor redescubra `end_time < start_time` \u00E0 sua maneira.\n crosses_midnight boolean NOT NULL GENERATED ALWAYS AS (end_time <= start_time) STORED,\n sort_order integer NOT NULL DEFAULT 0,\n -- O padr\u00E3o de onde a hora deste turno custa. A batida copia e pode divergir.\n -- FK condicional no fim do arquivo, como nas outras duas tabelas.\n chart_account_id uuid,\n cost_center_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_schedule_shifts_weekdays_check\n CHECK (weekdays <@ ARRAY[0,1,2,3,4,5,6]::smallint[])\n);\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_requirements (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n shift_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,\n -- Texto, e n\u00E3o FK para um cadastro de profiss\u00E3o: no V1 isto \u00E9 o nome livre que\n -- a tela mostra (\"Gar\u00E7om (1/1)\", \"Cozinha (0/3)\"), e o V2 ainda n\u00E3o tem o\n -- cadastro de profiss\u00E3o que seria o destino honesto. Inventar a FK agora seria\n -- criar um cadastro a partir de dado sujo; o texto atravessa e fica audit\u00E1vel.\n profession_name text NOT NULL,\n required_count integer NOT NULL DEFAULT 1 CHECK (required_count > 0),\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (shift_id, profession_name)\n);\n\nCOMMENT ON COLUMN public.plg_workforce_schedule_requirements.required_count IS\n 'A lota\u00E7\u00E3o da fun\u00E7\u00E3o no turno \u2014 o denominador do \"Gar\u00E7om (1/1)\" da tela. \u00C9 contra ele que a posi\u00E7\u00E3o por prioridade vira titular ou stand-by.';\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_template_assignments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n template_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE CASCADE,\n shift_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE CASCADE,\n profession_name text NOT NULL,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n -- COLUNA, e n\u00E3o posi\u00E7\u00E3o. No V1 titular vs stand-by no molde \u00E9 impl\u00EDcito \u2014 s\u00E3o\n -- titulares os primeiros `required_count` por `priority` \u2014 e essa regra s\u00F3\n -- existe no c\u00F3digo da tela. Aqui ela \u00E9 calculada uma vez, na entrada, e fica\n -- escrita: a mesma verdade que a inst\u00E2ncia j\u00E1 grava.\n role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),\n priority integer NOT NULL DEFAULT 0,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (shift_id, profession_name, person_id)\n);\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 6. A ESCALA: a inst\u00E2ncia do dia\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedules (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n schedule_date date NOT NULL,\n -- SET NULL e n\u00E3o CASCADE: apagar o molde n\u00E3o pode apagar a escala de ter\u00E7a\n -- passada, que \u00E9 registro de quem trabalhou. A c\u00F3pia do turno j\u00E1 est\u00E1 no\n -- snapshot da atribui\u00E7\u00E3o, ent\u00E3o a escala sobrevive sozinha ao molde.\n template_id uuid REFERENCES public.plg_workforce_schedule_templates(id) ON DELETE SET NULL,\n status text NOT NULL DEFAULT 'draft'\n CHECK (status IN ('draft', 'published', 'closed')),\n published_at timestamptz,\n published_by_user_id uuid,\n is_special_day boolean NOT NULL DEFAULT false,\n special_day_label text,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- Publicada sem carimbo de quando \u00E9 uma escala que ningu\u00E9m consegue provar\n -- ter avisado \u2014 e avisar \u00E9 a \u00FAnica coisa que a publica\u00E7\u00E3o faz.\n CONSTRAINT plg_workforce_schedules_published_check\n CHECK (status <> 'published' OR published_at IS NOT NULL)\n);\n\n-- \"Qual a escala deste dia?\" \u2014 \u00E9 o clique do calend\u00E1rio, e \u00E9 a consulta que a\n-- tela faz primeiro em toda sess\u00E3o.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedules_day_idx\n ON public.plg_workforce_schedules (tenant_id, unit_id, schedule_date DESC, status);\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_schedule_assignments (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n schedule_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedules(id) ON DELETE CASCADE,\n -- A refer\u00EAncia ao turno do molde \u00E9 conveni\u00EAncia, n\u00E3o depend\u00EAncia: o snapshot\n -- abaixo \u00E9 quem manda. Por isso SET NULL.\n shift_id uuid REFERENCES public.plg_workforce_schedule_shifts(id) ON DELETE SET NULL,\n -- SNAPSHOT, copiado na cria\u00E7\u00E3o da escala, exatamente como o V1 faz. Mudar o\n -- molde n\u00E3o pode reescrever a escala de anteontem: o que a pessoa foi chamada\n -- para cumprir \u00E9 um fato daquele dia, e \u00E9 contra ESTE hor\u00E1rio que o ponto dela\n -- \u00E9 conferido.\n shift_label text,\n shift_start_time time,\n shift_end_time time,\n profession_name text,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE RESTRICT,\n role text NOT NULL DEFAULT 'titular' CHECK (role IN ('titular', 'standby')),\n priority integer NOT NULL DEFAULT 0,\n status text NOT NULL DEFAULT 'pending'\n CHECK (status IN ('pending', 'notified', 'confirmed', 'declined', 'realized')),\n -- O convite que sai por link, sem sess\u00E3o. \u00DAnico no tenant porque \u00E9 uma\n -- credencial: dois convites com o mesmo token s\u00E3o duas pessoas confirmando\n -- pelo mesmo endere\u00E7o.\n response_token text,\n response_channel text,\n notified_at timestamptz,\n responded_at timestamptz,\n -- A PONTE COM A AGENDA, e a raz\u00E3o de a escala n\u00E3o morar l\u00E1. O RH oferta; ao\n -- publicar, a atribui\u00E7\u00E3o materializa uma linha de `public.schedules` com\n -- kind 'work_shift' e a agenda consome. A coluna \u00E9 o que torna o cruzamento\n -- previsto \u00D7 realizado uma chave em vez de um casamento por nome e hora.\n schedule_row_id uuid REFERENCES public.schedules(id) ON DELETE SET NULL,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- A mesma pessoa n\u00E3o ocupa a mesma vaga duas vezes no mesmo dia. No V1 nada\n -- impede, e o resultado \u00E9 a lota\u00E7\u00E3o \"2/1\" que a tela n\u00E3o sabe desenhar.\n UNIQUE (schedule_id, shift_id, profession_name, person_id)\n);\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_token_uq\n ON public.plg_workforce_schedule_assignments (tenant_id, response_token)\n WHERE response_token IS NOT NULL;\n\n-- \"Quem est\u00E1 escalado hoje, e quem ainda n\u00E3o respondeu?\" \u2014 os contadores\n-- \"N titulares \u00B7 N pendentes\" do cart\u00E3o da escala.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_schedule_idx\n ON public.plg_workforce_schedule_assignments (schedule_id, role, status);\n\n-- \"O que ESTA pessoa tem pela frente?\" \u2014 a folha individual e o convite.\nCREATE INDEX IF NOT EXISTS plg_workforce_schedule_assignments_person_idx\n ON public.plg_workforce_schedule_assignments (tenant_id, person_id, status);\n\n-- O log da atribui\u00E7\u00E3o: quem mudou o qu\u00EA, por qual canal. No V1 \u00E9\n-- `schedule_assignment_logs`, e \u00E9 a \u00FAnica parte do modelo de escala dele que j\u00E1\n-- nasceu append-only.\nCREATE TABLE IF NOT EXISTS public.plg_workforce_assignment_logs (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid REFERENCES app.units(id),\n assignment_id uuid NOT NULL\n REFERENCES public.plg_workforce_schedule_assignments(id) ON DELETE CASCADE,\n from_status text,\n to_status text NOT NULL,\n channel text,\n note text,\n changed_by_user_id uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS plg_workforce_assignment_logs_assignment_idx\n ON public.plg_workforce_assignment_logs (assignment_id, created_at DESC);\n\n-- \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\n--\n-- Em la\u00E7o porque s\u00E3o seis vezes a mesma disciplina: o gatilho de updated_at, o\n-- GRANT para os dois pap\u00E9is que existem, e o scaffold desenhando a policy. Seis\n-- blocos copiados divergiriam no primeiro dia em que algu\u00E9m editasse um s\u00F3.\nDO $$\nDECLARE\n r record;\nBEGIN\n FOR r IN\n SELECT * FROM (VALUES\n ('plg_workforce_schedule_templates', 'workforce.schedule_template'),\n ('plg_workforce_schedule_shifts', 'workforce.schedule_shift'),\n ('plg_workforce_schedule_requirements', 'workforce.schedule_requirement'),\n ('plg_workforce_schedule_template_assignments', 'workforce.schedule_template_assignment'),\n ('plg_workforce_schedules', 'workforce.schedule'),\n ('plg_workforce_schedule_assignments', 'workforce.schedule_assignment'),\n ('plg_workforce_assignment_logs', 'workforce.assignment_log')\n ) AS t(tbl, resource)\n LOOP\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',\n r.tbl || '_updated_at', r.tbl);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated, service_role', r.tbl);\n PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce', false, 'enforce');\n END LOOP;\nEND $$;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 7. O CRUZAMENTO previsto \u00D7 realizado (lacuna b)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n-- A hora que VALE em cada batida: a \u00FAltima corre\u00E7\u00E3o n\u00E3o anulada, ou o hor\u00E1rio\n-- real. \u00C9 a leitura que substitui as colunas `*_adjusted_at` do V1 sem perder a\n-- propriedade que o V1 acertou \u2014 o hor\u00E1rio real continua ali, intocado.\n--\n-- `security_invoker` em todas as views desta se\u00E7\u00E3o: uma folha de ponto que\n-- ignora escopo \u00E9 um vazamento com totais.\n-- REPLAY: `DROP` e depois `CREATE`, e n\u00E3o `CREATE OR REPLACE`.\n--\n-- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,\n-- `CREATE OR REPLACE` faz; TIRAR, n\u00E3o faz \u2014 e \u00E9 o que a baseline faria numa\n-- segunda passada, porque a vers\u00E3o dela \u00E9 a estreita. O cluster devolve\n-- `42P16: cannot drop columns from view` e a corrente para aqui.\n--\n-- Conferido em `pg_depend` antes de derrubar: nada pende destas duas views,\n-- ent\u00E3o o `DROP` \u00E9 sem `CASCADE` \u2014 de prop\u00F3sito. Sem ele, o dia em que\n-- algu\u00E9m construir sobre elas o `DROP` falha alto em vez de levar junto.\n-- Na ordem da depend\u00EAncia: `v_workforce_day` \u00E9 constru\u00EDda SOBRE esta, ent\u00E3o\n-- sai primeiro. Sem isso o `DROP` de baixo recusa \u2014 e recusar \u00E9 o certo,\n-- foi assim que esta depend\u00EAncia apareceu.\nDROP VIEW IF EXISTS public.v_workforce_day;\nDROP VIEW IF EXISTS public.v_workforce_time_entries;\nCREATE VIEW public.v_workforce_time_entries\nWITH (security_invoker = 'true') AS\nSELECT e.id,\n e.tenant_id,\n e.unit_id,\n e.person_id,\n e.person_name,\n e.kind,\n e.work_date,\n e.seq,\n e.origin,\n e.punched_at,\n coalesce(c.effective_at, e.punched_at) AS effective_at,\n (c.id IS NOT NULL) AS adjusted,\n (v.id IS NOT NULL) AS voided,\n c.reason AS adjustment_reason,\n c.adjusted_by_name,\n e.assignment_id,\n e.payable_invoice_id,\n e.evidence,\n e.metadata\n FROM public.plg_workforce_time_entries e\n LEFT JOIN LATERAL (\n SELECT a.id, a.effective_at, a.reason, a.adjusted_by_name\n FROM public.plg_workforce_time_adjustments a\n WHERE a.time_entry_id = e.id AND a.action IN ('correct', 'insert')\n ORDER BY a.created_at DESC, a.id DESC\n LIMIT 1) c ON true\n LEFT JOIN LATERAL (\n SELECT a.id FROM public.plg_workforce_time_adjustments a\n WHERE a.time_entry_id = e.id AND a.action = 'void'\n LIMIT 1) v ON true;\n\nCOMMENT ON VIEW public.v_workforce_time_entries IS\n 'A batida com a hora que vale: a \u00FAltima corre\u00E7\u00E3o n\u00E3o anulada, ou o hor\u00E1rio real. O real (punched_at) nunca some \u2014 \u00E9 o que torna o ponto audit\u00E1vel.';\n\n-- O dia de trabalho de uma pessoa, previsto ao lado de realizado.\n--\n-- FULL OUTER JOIN de prop\u00F3sito. As duas assimetrias s\u00E3o exatamente o que o V1\n-- n\u00E3o consegue mostrar, e s\u00E3o as duas perguntas do RH: previsto sem realizado \u00E9\n-- FALTA; realizado sem previsto \u00E9 FORA DA ESCALA (que no tenant medido \u00E9 o\n-- estado de 325 de 325 registros \u2014 n\u00E3o porque as pessoas trabalhem fora da\n-- escala, mas porque o ponto est\u00E1 ligado numa unidade e a escala em outra).\n--\n-- As horas trabalhadas saem de uma soma com sinal: clock_out e break_start\n-- FECHAM um trecho, clock_in e break_end ABREM. Quantos intervalos houver no\n-- dia, a conta \u00E9 a mesma \u2014 \u00E9 isso que o par \u00FAnico do V1 n\u00E3o consegue fazer.\n-- REPLAY: `DROP` e depois `CREATE`, e n\u00E3o `CREATE OR REPLACE`.\n--\n-- As migrations 003/004/005 ACRESCENTAM colunas a esta view. Acrescentar,\n-- `CREATE OR REPLACE` faz; TIRAR, n\u00E3o faz \u2014 e \u00E9 o que a baseline faria numa\n-- segunda passada, porque a vers\u00E3o dela \u00E9 a estreita. O cluster devolve\n-- `42P16: cannot drop columns from view` e a corrente para aqui.\n--\n-- Conferido em `pg_depend` antes de derrubar: nada pende destas duas views,\n-- ent\u00E3o o `DROP` \u00E9 sem `CASCADE` \u2014 de prop\u00F3sito. Sem ele, o dia em que\n-- algu\u00E9m construir sobre elas o `DROP` falha alto em vez de levar junto.\nDROP VIEW IF EXISTS public.v_workforce_day;\nCREATE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH realized AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end'))\n <> count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS is_open,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS worked_seconds\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia \u00E9 normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n -- As duas assimetrias, nomeadas. S\u00E3o as perguntas do RH, e nenhuma delas\n -- tem resposta no V1.\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista \u00D7 realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: `absent` \u00E9 escala sem ponto, `outside_schedule` \u00E9 ponto sem escala \u2014 as duas perguntas que o V1 n\u00E3o responde.';\n\nGRANT SELECT ON public.v_workforce_time_entries TO authenticated, service_role;\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 8. OS WRITERS da migra\u00E7\u00E3o\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n-- O vocabul\u00E1rio do de-para \u00E9 lista fechada de prop\u00F3sito: uma linha que nomeia um\n-- writer inexistente falha na hora, n\u00E3o na terceira hora da onda.\n--\n-- ACR\u00C9SCIMO, e n\u00E3o reemiss\u00E3o. A vers\u00E3o anterior reescrevia a lista INTEIRA \u2014\n-- \"a lista at\u00E9 aqui (046) mais os oito da jornada\". Uma lista escrita por\n-- extenso \u00E9 um retrato: ela apaga todo writer que outro passo registrou e que\n-- o retrato n\u00E3o viu. Medido no cluster vivo, re-aplicar esta baseline dava\n--\n-- 23514: check constraint \"allowlist_writer_check\" is violated by some row\n--\n-- porque l\u00E1 j\u00E1 havia writers que esta lista n\u00E3o nomeia. \u00C9 o mesmo defeito que\n-- `agenda/008` e `financial/072` j\u00E1 pagaram, e a forma abaixo \u00E9 a de\n-- `inventory/004`: leia o que existe, acrescente o que falta, nunca reemita.\nDO $$\nDECLARE\n v_def text;\n v_falta text[];\n v_meus text[] := ARRAY[\n 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift',\n 'wf_schedule_requirement', 'wf_schedule_template_assignment',\n 'wf_schedule', 'wf_schedule_assignment', 'wf_assignment_log'];\nBEGIN\n SELECT pg_get_constraintdef(oid) INTO v_def\n FROM pg_constraint WHERE conname = 'allowlist_writer_check';\n\n IF v_def IS NULL THEN\n -- Ningu\u00E9m escreveu a regra ainda: aqui ela nasce, com os writers desta\n -- jornada e os que a corrente j\u00E1 registrou at\u00E9 este ponto.\n ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check\n CHECK (writer IS NULL OR writer = ANY (ARRAY[\n 'person', 'unit', 'category', 'product', 'order', 'invoice', 'price_table',\n 'price_table_item', 'package', 'package_item', 'field_rule', 'holiday', 'facet',\n 'fin_account', 'fin_payment_method_type', 'fin_payment_method', 'fin_card_brand',\n 'fin_chart_account', 'fin_cost_center', 'inv_measurement_unit', 'inv_unit_conversion',\n 'inv_location', 'inv_movement', 'order_item', 'fin_movement', 'authz_role',\n 'authz_role_permission', 'person_registration',\n 'fin_recurring_bill', 'fin_recurring_bill_item', 'fin_recurring_bill_occurrence',\n 'wf_time_entry', 'wf_schedule_template', 'wf_schedule_shift', 'wf_schedule_requirement',\n 'wf_schedule_template_assignment', 'wf_schedule', 'wf_schedule_assignment',\n 'wf_assignment_log']));\n RETURN;\n END IF;\n\n SELECT array_agg(w) INTO v_falta\n FROM unnest(v_meus) AS w\n WHERE strpos(v_def, '''' || w || '''') = 0;\n\n IF v_falta IS NULL THEN RETURN; END IF;\n\n -- Entram na frente do primeiro elemento: a posi\u00E7\u00E3o n\u00E3o importa para um\n -- `= ANY`, e ancorar em `ARRAY[` dispensa conhecer qualquer valor da lista\n -- \u2014 que \u00E9 justamente o que n\u00E3o se pode presumir.\n ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;\n EXECUTE 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check '\n || replace(v_def, 'ARRAY[',\n 'ARRAY[' || (SELECT string_agg(quote_literal(w) || '::text, ', '')\n FROM unnest(v_falta) AS w));\nEND $$;\n\n-- \u2500\u2500 o de-para da pessoa polim\u00F3rfica \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Este helper \u00E9 a tradu\u00E7\u00E3o de `person_type` + `person_id` para a FK \u00FAnica do V2,\n-- e existe uma vez porque quatro writers precisam dela. O ledger \u00E9 a \u00FAnica fonte\n-- que sabe o de-para, e ele guarda a TABELA de origem \u2014 ent\u00E3o 'professional' e\n-- 'employee' resolvem pelo mesmo caminho, e o writer n\u00E3o precisa conhecer\n-- nenhuma das duas.\nCREATE OR REPLACE FUNCTION migration._wf_person(p_tenant uuid, p_meta jsonb)\n RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''\nAS $$\n SELECT l.target_id\n FROM migration.ledger l\n WHERE l.tenant_id = p_tenant\n AND l.source_table = CASE WHEN (p_meta #>> '{v1,person_type}') = 'professional'\n THEN 'professionals' ELSE 'employees' END\n AND l.source_numeric_id = nullif(p_meta #>> '{v1,person_id}', '')::bigint\n AND l.target_table = 'public.people'\n AND l.status = 'migrated'\n LIMIT 1;\n$$;\n\nCOMMENT ON FUNCTION migration._wf_person(uuid, jsonb) IS\n 'person_type + person_id do V1 \u2192 o uuid de public.people. Duas tabelas de cadastro no V1, um cadastro s\u00F3 no V2.';\n\n-- Helper de migra\u00E7\u00E3o l\u00EA o ledger inteiro do tenant: n\u00E3o \u00E9 fun\u00E7\u00E3o de aplica\u00E7\u00E3o.\nREVOKE ALL ON FUNCTION migration._wf_person(uuid, jsonb) FROM PUBLIC, anon, authenticated;\n\n-- \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\n--\n-- UMA linha do V1 vira AT\u00C9 QUATRO linhas do V2: a entrada, a sa\u00EDda (quando\n-- existe), e o ajuste de cada uma (quando `*_adjusted_at` est\u00E1 preenchido). \u00C9 a\n-- tradu\u00E7\u00E3o do par-com-colunas-de-ajuste para eventos-com-ajuste-em-linha.\n--\n-- Os ids derivados s\u00E3o determin\u00EDsticos (md5 do id de origem + o sufixo do papel)\n-- para que reoferecer a mesma linha numa segunda passada ATUALIZE em vez de\n-- duplicar o dia de trabalho de algu\u00E9m. `target_id` devolvido \u00E9 o da ENTRADA:\n-- \u00E9 a linha do V1 que ela representa.\nCREATE OR REPLACE FUNCTION migration.upsert_wf_time_entry(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_person uuid;\n v_unit uuid := (p_row ->> 'unit_id')::uuid;\n v_in timestamptz := (p_row ->> 'checkin_at')::timestamptz;\n v_out timestamptz := (p_row ->> 'checkout_at')::timestamptz;\n v_in_adj timestamptz := (p_row ->> 'checkin_adjusted_at')::timestamptz;\n v_out_adj timestamptz := (p_row ->> 'checkout_adjusted_at')::timestamptz;\n v_work_date date;\n v_out_id uuid;\n v_reason text := coalesce(nullif(btrim(coalesce(p_row ->> 'adjustment_reason', '')), ''),\n 'ajuste importado do V1 sem motivo registrado');\n v_evidence jsonb;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n\n IF p_id IS NULL OR v_in IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma batida precisa de id e de hora de entrada');\n END IF;\n\n -- Recusar \u00E9 o certo, e \u00E9 tempor\u00E1rio: a pessoa entra na mesma onda, e a segunda\n -- passada oferece a batida de novo. Inventar a pessoa aqui seria pendurar\n -- horas de trabalho \u2014 e portanto dinheiro \u2014 em quem n\u00E3o existe.\n v_person := migration._wf_person(p_tenant, v_meta);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('batida de %s #%s cuja pessoa ainda n\u00E3o migrou',\n coalesce(v_meta #>> '{v1,person_type}', '<sem tipo>'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n -- A jornada \u00E9 do dia da ENTRADA, sempre \u2014 mesmo quando a sa\u00EDda \u00E9 do dia\n -- seguinte. \u00C9 a regra que o V1 admite na caixa de ajuste e nunca grava.\n v_work_date := coalesce((p_row ->> 'work_date')::date, v_in::date);\n v_out_id := md5(p_id::text || ':clock_out')::uuid;\n\n -- Todo o conjunto de prova do totem numa chave s\u00F3. Perder qualquer um destes\n -- campos \u00E9 perder a defesa de uma batida contestada.\n v_evidence := jsonb_strip_nulls(jsonb_build_object(\n 'photo_path', p_row ->> 'checkin_photo_path',\n 'lat', (p_row ->> 'checkin_lat')::numeric,\n 'lng', (p_row ->> 'checkin_lng')::numeric,\n 'accuracy_m', (p_row ->> 'checkin_accuracy_m')::numeric,\n 'ip', p_row ->> 'checkin_ip',\n 'user_agent', p_row ->> 'checkin_user_agent',\n 'face_score', (p_row ->> 'checkin_face_score')::numeric));\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_time_entries\n (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,\n origin, evidence, assignment_id, notes, metadata)\n VALUES (p_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_in', v_in,\n v_work_date, 1, 'migration', v_evidence,\n (p_row ->> 'assignment_id')::uuid, p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_time_entries SET\n unit_id = coalesce(v_unit, unit_id),\n person_id = v_person,\n person_name = coalesce(p_row ->> 'person_name', person_name),\n punched_at = v_in,\n work_date = v_work_date,\n evidence = migration._merge_meta(evidence, v_evidence),\n assignment_id = coalesce((p_row ->> 'assignment_id')::uuid, assignment_id),\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n -- A sa\u00EDda s\u00F3 existe quando existe. `checkout_at IS NULL` \u00E9 o \"ponto em aberto\"\n -- do V1 \u2014 20 deles no tenant medido \u2014 e ele atravessa como aus\u00EAncia de evento,\n -- que \u00E9 o que ele \u00E9. Fabricar uma sa\u00EDda para \"fechar\" o registro inventaria\n -- horas trabalhadas.\n IF v_out IS NOT NULL THEN\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_time_entries WHERE id = v_out_id) THEN\n INSERT INTO public.plg_workforce_time_entries\n (id, tenant_id, unit_id, person_id, person_name, kind, punched_at, work_date, seq,\n origin, evidence, assignment_id, metadata)\n VALUES (v_out_id, p_tenant, v_unit, v_person, p_row ->> 'person_name', 'clock_out', v_out,\n v_work_date, 2, 'migration',\n jsonb_strip_nulls(jsonb_build_object(\n 'photo_path', p_row ->> 'checkout_photo_path',\n 'lat', (p_row ->> 'checkout_lat')::numeric,\n 'lng', (p_row ->> 'checkout_lng')::numeric,\n 'accuracy_m', (p_row ->> 'checkout_accuracy_m')::numeric,\n 'ip', p_row ->> 'checkout_ip',\n 'user_agent', p_row ->> 'checkout_user_agent',\n 'face_score', (p_row ->> 'checkout_face_score')::numeric)),\n (p_row ->> 'assignment_id')::uuid,\n migration._deep_set(v_meta, ARRAY['migration', 'derived_from'], to_jsonb(p_id::text)));\n ELSE\n UPDATE public.plg_workforce_time_entries SET\n punched_at = v_out, work_date = v_work_date, person_id = v_person\n WHERE id = v_out_id;\n END IF;\n END IF;\n\n -- O ajuste vira LINHA. No V1 ele \u00E9 um par de colunas por cima da batida, e o\n -- hist\u00F3rico morre no segundo ajuste; aqui cada corre\u00E7\u00E3o fica, com autor e\n -- motivo. O motivo \u00E9 opcional no V1 e obrigat\u00F3rio aqui \u2014 quando vem vazio, o\n -- texto diz exatamente isso, em vez de fingir que algu\u00E9m justificou.\n IF v_in_adj IS NOT NULL THEN\n INSERT INTO public.plg_workforce_time_adjustments\n (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,\n adjusted_by_user_id, adjusted_by_name, metadata)\n VALUES (md5(p_id::text || ':adjust_in')::uuid, p_tenant, v_unit, p_id, 'correct',\n v_in_adj, v_reason,\n (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',\n migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkin_adjusted_at'::text)))\n ON CONFLICT (id) DO UPDATE\n SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,\n adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();\n END IF;\n\n IF v_out_adj IS NOT NULL AND v_out IS NOT NULL THEN\n INSERT INTO public.plg_workforce_time_adjustments\n (id, tenant_id, unit_id, time_entry_id, action, effective_at, reason,\n adjusted_by_user_id, adjusted_by_name, metadata)\n VALUES (md5(p_id::text || ':adjust_out')::uuid, p_tenant, v_unit, v_out_id, 'correct',\n v_out_adj, v_reason,\n (p_row ->> 'adjusted_by_user_id')::uuid, p_row ->> 'adjusted_by_name',\n migration._deep_set(v_meta, ARRAY['migration', 'from'], to_jsonb('checkout_adjusted_at'::text)))\n ON CONFLICT (id) DO UPDATE\n SET effective_at = EXCLUDED.effective_at, reason = EXCLUDED.reason,\n adjusted_by_name = EXCLUDED.adjusted_by_name, updated_at = now();\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_time_entries', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,\n 'entries', CASE WHEN v_out IS NULL THEN 1 ELSE 2 END,\n 'open', v_out IS NULL);\nEND $function$;\n\n-- \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\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_name IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um template de escala precisa de id e de nome \u2014 \u00E9 por ele que a tela o oferece');\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_templates WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_templates (id, tenant_id, unit_id, name, is_active, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_name,\n coalesce((p_row ->> 'is_active')::boolean, true), p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_templates SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n name = v_name,\n is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_templates', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_shift(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_template uuid := (p_row ->> 'template_id')::uuid;\n v_start time := (p_row ->> 'start_time')::time;\n v_end time := (p_row ->> 'end_time')::time;\n v_days smallint[];\n v_bad smallint[];\n v_unit uuid;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_template IS NULL OR v_start IS NULL OR v_end IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'um turno precisa de id, do template, de in\u00EDcio e de fim');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_templates\n WHERE id = v_template AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o template %s n\u00E3o existe neste tenant', v_template));\n END IF;\n\n -- Sem corre\u00E7\u00E3o silenciosa nos dias da semana. Um 7 solto (ou um 1..7 em vez de\n -- 0..6) \u00E9 um turno que nunca acontece, e um turno que nunca acontece \u00E9 uma\n -- pessoa que ningu\u00E9m chamou \u2014 some sem erro nenhum.\n SELECT coalesce(array_agg(x::smallint ORDER BY x), '{}'::smallint[])\n INTO v_days\n FROM jsonb_array_elements_text(coalesce(p_row -> 'weekdays', '[]'::jsonb)) AS t(x);\n SELECT coalesce(array_agg(d), '{}'::smallint[]) INTO v_bad\n FROM unnest(v_days) AS d WHERE d < 0 OR d > 6;\n IF array_length(v_bad, 1) > 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('dia da semana fora de 0..6 (domingo..s\u00E1bado): %s', v_bad::text));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_shifts WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_shifts\n (id, tenant_id, unit_id, template_id, label, start_time, end_time, weekdays, sort_order, metadata)\n VALUES (p_id, p_tenant, v_unit, v_template,\n coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), 'Turno'),\n v_start, v_end, v_days, coalesce((p_row ->> 'sort_order')::integer, 0), v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_shifts SET\n unit_id = v_unit,\n label = coalesce(nullif(btrim(coalesce(p_row ->> 'label', '')), ''), label),\n start_time = v_start,\n end_time = v_end,\n weekdays = v_days,\n sort_order = CASE WHEN p_row ? 'sort_order' THEN coalesce((p_row ->> 'sort_order')::integer, sort_order) ELSE sort_order END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_shifts', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END,\n 'crosses_midnight', v_end <= v_start);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_requirement(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_shift uuid := (p_row ->> 'shift_id')::uuid;\n v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');\n v_count integer := coalesce((p_row ->> 'required_count')::integer, 1);\n v_unit uuid;\n v_id uuid;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_shift IS NULL OR v_prof IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma vaga precisa de id, do turno e do nome da fun\u00E7\u00E3o');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_shifts\n WHERE id = v_shift AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o turno %s n\u00E3o existe neste tenant', v_shift));\n END IF;\n -- Lota\u00E7\u00E3o zero ou negativa \u00E9 vaga que n\u00E3o existe; e a constraint recusaria a\n -- linha inteira mais tarde, sem dizer qual era.\n IF v_count <= 0 THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('lota\u00E7\u00E3o de %L \u00E9 %s \u2014 uma vaga precisa de pelo menos uma pessoa', v_prof, v_count));\n END IF;\n\n -- Devolve o id da linha que de fato ficou: quando duas vagas do V1 nomeiam a\n -- mesma fun\u00E7\u00E3o no mesmo turno, a segunda funde na primeira, e o ledger tem de\n -- apontar para onde o dado foi parar \u2014 n\u00E3o para o id que ela trazia.\n INSERT INTO public.plg_workforce_schedule_requirements\n (id, tenant_id, unit_id, shift_id, profession_name, required_count, metadata)\n VALUES (p_id, p_tenant, v_unit, v_shift, v_prof, v_count, v_meta)\n ON CONFLICT (shift_id, profession_name) DO UPDATE\n SET required_count = EXCLUDED.required_count,\n metadata = migration._merge_meta(public.plg_workforce_schedule_requirements.metadata, EXCLUDED.metadata),\n updated_at = now()\n RETURNING id INTO v_id;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_requirements', v_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', v_id,\n 'reason', CASE WHEN v_id = p_id THEN 'upserted' ELSE 'fundida na vaga que j\u00E1 existia para esta fun\u00E7\u00E3o neste turno' END);\nEND $function$;\n\n-- No molde do V1, titular vs stand-by \u00E9 POSICIONAL \u2014 os primeiros\n-- `required_count` por `priority`. Este writer faz essa conta UMA vez, na\n-- entrada, e grava o resultado em `role`. Deixar a regra impl\u00EDcita no V2 seria\n-- repor um comportamento que s\u00F3 existe no c\u00F3digo de uma tela, e que a pr\u00F3xima\n-- tela a ler a mesma tabela erraria em sil\u00EAncio.\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_template_assignment(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_shift uuid := (p_row ->> 'shift_id')::uuid;\n v_prof text := nullif(btrim(coalesce(p_row ->> 'profession_name', '')), '');\n v_priority integer := coalesce((p_row ->> 'priority')::integer, 0);\n v_person uuid;\n v_template uuid;\n v_unit uuid;\n v_required integer;\n v_rank integer;\n v_role text;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_shift IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma aloca\u00E7\u00E3o de molde precisa de id e do turno');\n END IF;\n\n -- No molde a pessoa \u00E9 sempre `professionals` \u2014 o V1 n\u00E3o escala funcion\u00E1rio\n -- pelo template \u2014 mas o helper resolve pelo que o metadata disser, e n\u00E3o pelo\n -- que este writer sup\u00F5e.\n v_person := migration._wf_person(p_tenant,\n CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL\n THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))\n ELSE v_meta END);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('aloca\u00E7\u00E3o de molde cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,person_type}', 'professional'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n SELECT s.template_id, s.unit_id INTO v_template, v_unit\n FROM public.plg_workforce_schedule_shifts s\n WHERE s.id = v_shift AND s.tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('o turno %s n\u00E3o existe neste tenant', v_shift));\n END IF;\n\n INSERT INTO public.plg_workforce_schedule_template_assignments\n (id, tenant_id, unit_id, template_id, shift_id, profession_name, person_id, role, priority, metadata)\n VALUES (p_id, p_tenant, v_unit, v_template, v_shift, coalesce(v_prof, '\u2014'), v_person,\n 'titular', v_priority, v_meta)\n ON CONFLICT (shift_id, profession_name, person_id) DO UPDATE\n SET priority = EXCLUDED.priority,\n metadata = migration._merge_meta(public.plg_workforce_schedule_template_assignments.metadata, EXCLUDED.metadata),\n updated_at = now();\n\n -- A POSI\u00C7\u00C3O vira PAPEL. Recalculada a cada linha porque a ordem muda quando a\n -- pr\u00F3xima chega: a quarta pessoa de uma vaga de tr\u00EAs rebaixa ningu\u00E9m, mas\n -- entra como reserva, e a s\u00E9tima tamb\u00E9m.\n SELECT r.required_count INTO v_required\n FROM public.plg_workforce_schedule_requirements r\n WHERE r.shift_id = v_shift AND r.profession_name = coalesce(v_prof, '\u2014');\n\n SELECT rank INTO v_rank FROM (\n SELECT a.id, row_number() OVER (ORDER BY a.priority, a.created_at, a.id) AS rank\n FROM public.plg_workforce_schedule_template_assignments a\n WHERE a.shift_id = v_shift AND a.profession_name = coalesce(v_prof, '\u2014')) s\n WHERE s.id = p_id;\n\n -- Sem vaga declarada n\u00E3o h\u00E1 denominador, e chutar um faria a pessoa virar\n -- titular ou reserva por acaso. `titular` \u00E9 o que o V1 mostra nesse caso.\n v_role := CASE WHEN v_required IS NULL THEN 'titular'\n WHEN v_rank <= v_required THEN 'titular'\n ELSE 'standby' END;\n UPDATE public.plg_workforce_schedule_template_assignments SET role = v_role WHERE id = p_id;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_template_assignments', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,\n 'reason', format('posi\u00E7\u00E3o %s de %s vaga(s) \u2192 %s', v_rank, coalesce(v_required::text, 'sem lota\u00E7\u00E3o declarada'), v_role));\nEND $function$;\n\n-- \u2500\u2500 a inst\u00E2ncia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_date date := (p_row ->> 'schedule_date')::date;\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_status text;\n v_published timestamptz := (p_row ->> 'published_at')::timestamptz;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_date IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma escala precisa de id e do dia a que pertence');\n END IF;\n\n v_status := CASE v_status_v1\n WHEN 'draft' THEN 'draft'\n WHEN 'published' THEN 'published'\n WHEN 'closed' THEN 'closed'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de escala desconhecido: %L (o V2 conhece draft, published e closed)', v_status_v1));\n END IF;\n\n -- Publicada sem carimbo \u00E9 o rascunho do V1 que algu\u00E9m marcou como publicado\n -- sem a a\u00E7\u00E3o que grava a data. A constraint recusaria; aqui a data de\n -- publica\u00E7\u00E3o vira a do pr\u00F3prio dia da escala, e o remendo fica escrito.\n IF v_status <> 'draft' AND v_published IS NULL THEN\n v_published := v_date::timestamptz;\n v_meta := migration._deep_set(v_meta, ARRAY['migration', 'published_at_inferred'], to_jsonb(true));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedules WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedules\n (id, tenant_id, unit_id, schedule_date, template_id, status, published_at,\n is_special_day, special_day_label, notes, metadata)\n VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_date,\n (p_row ->> 'template_id')::uuid, v_status, v_published,\n coalesce((p_row ->> 'is_special_day')::boolean, false),\n p_row ->> 'special_day_label', p_row ->> 'notes', v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedules SET\n unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,\n schedule_date = v_date,\n template_id = CASE WHEN p_row ? 'template_id' THEN (p_row ->> 'template_id')::uuid ELSE template_id END,\n status = v_status,\n published_at = v_published,\n is_special_day = CASE WHEN p_row ? 'is_special_day' THEN coalesce((p_row ->> 'is_special_day')::boolean, false) ELSE is_special_day END,\n special_day_label = CASE WHEN p_row ? 'special_day_label' THEN p_row ->> 'special_day_label' ELSE special_day_label END,\n notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedules', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_schedule_assignment(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_schedule uuid := (p_row ->> 'schedule_id')::uuid;\n v_role_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'role', ''))), '');\n v_status_v1 text := nullif(btrim(lower(coalesce(p_row ->> 'status', ''))), '');\n v_role text;\n v_status text;\n v_person uuid;\n v_unit uuid;\n v_start time := (p_row ->> 'shift_start_time')::time;\n v_end time := (p_row ->> 'shift_end_time')::time;\n v_created boolean := false;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_schedule IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma atribui\u00E7\u00E3o precisa de id e da escala a que pertence');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedules\n WHERE id = v_schedule AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a escala %s n\u00E3o existe neste tenant', v_schedule));\n END IF;\n\n v_person := migration._wf_person(p_tenant,\n CASE WHEN (v_meta #>> '{v1,person_type}') IS NULL\n THEN migration._deep_set(v_meta, ARRAY['v1', 'person_type'], to_jsonb('professional'::text))\n ELSE v_meta END);\n IF v_person IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('atribui\u00E7\u00E3o de escala cuja pessoa ainda n\u00E3o migrou (%s #%s)',\n coalesce(v_meta #>> '{v1,person_type}', 'professional'),\n coalesce(v_meta #>> '{v1,person_id}', '<sem id>')));\n END IF;\n\n -- Stand-by N\u00C3O \u00E9 folga: \u00E9 o substituto acion\u00E1vel, e a pr\u00F3pria tela do V1 diz\n -- (\"Substitutos em caso de falta\"). Confundir os dois na tradu\u00E7\u00E3o apagaria a\n -- fila de quem cobre a falta.\n v_role := CASE v_role_v1\n WHEN 'titular' THEN 'titular'\n WHEN 'standby' THEN 'standby'\n WHEN 'stand_by' THEN 'standby'\n WHEN 'stand-by' THEN 'standby'\n ELSE NULL END;\n IF v_role IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('papel na vaga desconhecido: %L (o V2 conhece titular e standby)', v_role_v1));\n END IF;\n\n v_status := CASE v_status_v1\n WHEN 'pending' THEN 'pending'\n WHEN 'notified' THEN 'notified'\n WHEN 'confirmed' THEN 'confirmed'\n WHEN 'declined' THEN 'declined'\n WHEN 'realized' THEN 'realized'\n ELSE NULL END;\n IF v_status IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',\n 'reason', format('estado de atribui\u00E7\u00E3o desconhecido: %L', v_status_v1));\n END IF;\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_workforce_schedule_assignments WHERE id = p_id) THEN\n INSERT INTO public.plg_workforce_schedule_assignments\n (id, tenant_id, unit_id, schedule_id, shift_id, shift_label, shift_start_time, shift_end_time,\n profession_name, person_id, role, priority, status, response_token, response_channel,\n notified_at, responded_at, metadata)\n VALUES (p_id, p_tenant, v_unit, v_schedule, (p_row ->> 'shift_id')::uuid,\n p_row ->> 'shift_label', v_start, v_end,\n p_row ->> 'profession_name', v_person, v_role,\n coalesce((p_row ->> 'priority')::integer, 0), v_status,\n -- O token do V1 \u00E9 uma credencial viva de um canal que o V2 ainda n\u00E3o\n -- opera. Ele atravessa para n\u00E3o perder o rastro de quem foi chamado\n -- por link, mas quem emitir o pr\u00F3ximo convite emite outro.\n p_row ->> 'response_token', p_row ->> 'response_channel',\n (p_row ->> 'notified_at')::timestamptz, (p_row ->> 'responded_at')::timestamptz,\n v_meta);\n v_created := true;\n ELSE\n UPDATE public.plg_workforce_schedule_assignments SET\n unit_id = v_unit,\n shift_id = CASE WHEN p_row ? 'shift_id' THEN (p_row ->> 'shift_id')::uuid ELSE shift_id END,\n shift_label = CASE WHEN p_row ? 'shift_label' THEN p_row ->> 'shift_label' ELSE shift_label END,\n shift_start_time = coalesce(v_start, shift_start_time),\n shift_end_time = coalesce(v_end, shift_end_time),\n profession_name = CASE WHEN p_row ? 'profession_name' THEN p_row ->> 'profession_name' ELSE profession_name END,\n person_id = v_person,\n role = v_role,\n priority = CASE WHEN p_row ? 'priority' THEN coalesce((p_row ->> 'priority')::integer, priority) ELSE priority END,\n status = v_status,\n response_token = CASE WHEN p_row ? 'response_token' THEN p_row ->> 'response_token' ELSE response_token END,\n response_channel = CASE WHEN p_row ? 'response_channel' THEN p_row ->> 'response_channel' ELSE response_channel END,\n notified_at = CASE WHEN p_row ? 'notified_at' THEN (p_row ->> 'notified_at')::timestamptz ELSE notified_at END,\n responded_at = CASE WHEN p_row ? 'responded_at' THEN (p_row ->> 'responded_at')::timestamptz ELSE responded_at END,\n metadata = migration._merge_meta(metadata, v_meta)\n WHERE id = p_id;\n END IF;\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_schedule_assignments', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'role', v_role,\n 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);\nEND $function$;\n\nCREATE OR REPLACE FUNCTION migration.upsert_wf_assignment_log(\n p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''\nAS $function$\nDECLARE\n v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);\n v_assignment uuid := (p_row ->> 'assignment_id')::uuid;\n v_to text := nullif(btrim(lower(coalesce(p_row ->> 'to_status', ''))), '');\n v_unit uuid;\n v_standalone boolean := migration._standalone();\nBEGIN\n IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;\n IF p_id IS NULL OR v_assignment IS NULL OR v_to IS NULL THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',\n 'reason', 'uma linha de hist\u00F3rico precisa de id, da atribui\u00E7\u00E3o e do estado para o qual foi');\n END IF;\n SELECT unit_id INTO v_unit FROM public.plg_workforce_schedule_assignments\n WHERE id = v_assignment AND tenant_id = p_tenant;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',\n 'reason', format('a atribui\u00E7\u00E3o %s n\u00E3o existe neste tenant', v_assignment));\n END IF;\n\n -- O log \u00E9 hist\u00F3rico: reoferecer a mesma linha \u00E9 replay, e replay n\u00E3o reescreve\n -- o que j\u00E1 foi registrado.\n IF EXISTS (SELECT 1 FROM public.plg_workforce_assignment_logs WHERE id = p_id) THEN\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,\n 'reason', 'already in the ledger (append-only)');\n END IF;\n\n INSERT INTO public.plg_workforce_assignment_logs\n (id, tenant_id, unit_id, assignment_id, from_status, to_status, channel, note,\n changed_by_user_id, metadata, created_at)\n VALUES (p_id, p_tenant, v_unit, v_assignment,\n nullif(btrim(lower(coalesce(p_row ->> 'from_status', ''))), ''), v_to,\n p_row ->> 'channel', p_row ->> 'note',\n (p_row ->> 'changed_by_user_id')::uuid, v_meta,\n coalesce((p_row ->> 'created_at')::timestamptz, now()));\n\n IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_workforce_assignment_logs', p_id, '{}'::jsonb); END IF;\n RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');\nEND $function$;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 9. O DE-PARA\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n-- `_allow` \u00E9 o construtor oficial: a allowlist tem gatilho de congelamento, e um\n-- INSERT direto por cima de uma linha `frozen` seria recusado. S\u00E3o oito\n-- argumentos \u2014 (source, target, domain, writer, writer_args, financial[],\n-- columns, notes) \u2014 e `layer` \u00E9 fixo em 'core-A' l\u00E1 dentro.\n\n-- A allowlist \u00E9 CONGELADA (`trg_allowlist_frozen`): mudan\u00E7a de core-A \u00E9\n-- arquivo de migration, n\u00E3o escrita solta \u2014 e este \u00E9 o arquivo. Sem a\n-- escotilha, a PRIMEIRA passada passa (a linha ainda n\u00E3o existia) e a\n-- SEGUNDA falha: \u00E9 assim que uma migration deixa de ser replay-safe sem\n-- ningu\u00E9m ver. `true` no terceiro argumento: vale s\u00F3 nesta transa\u00E7\u00E3o.\nSELECT set_config('migration.allowlist_unlock', 'on', true);\n\nSELECT migration._allow(\n 'time_clock_entries', 'public.plg_workforce_time_entries', 'workforce',\n 'wf_time_entry', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n -- O par polim\u00F3rfico. Nenhuma das duas vira coluna: juntas elas viram\n -- `person_id`, resolvido pelo ledger em migration._wf_person.\n 'person_type', 'jsonb:v1.person_type',\n 'person_id', 'jsonb:v1.person_id',\n 'person_name', 'person_name',\n 'checkin_at', 'checkin_at',\n 'checkout_at', 'checkout_at',\n 'checkin_photo_path', 'checkin_photo_path',\n 'checkin_lat', 'checkin_lat',\n 'checkin_lng', 'checkin_lng',\n 'checkin_accuracy_m', 'checkin_accuracy_m',\n 'checkin_ip', 'checkin_ip',\n 'checkin_user_agent', 'checkin_user_agent',\n 'checkin_face_score', 'checkin_face_score',\n 'checkout_photo_path', 'checkout_photo_path',\n 'checkout_lat', 'checkout_lat',\n 'checkout_lng', 'checkout_lng',\n 'checkout_accuracy_m', 'checkout_accuracy_m',\n 'checkout_ip', 'checkout_ip',\n 'checkout_user_agent', 'checkout_user_agent',\n 'checkout_face_score', 'checkout_face_score',\n 'schedule_assignment_id','assignment_id@schedule_assignments',\n -- O ajuste do V1 n\u00E3o vira coluna: vira LINHA em\n -- plg_workforce_time_adjustments, com autor e motivo.\n 'checkin_adjusted_at', 'checkin_adjusted_at',\n 'checkout_adjusted_at', 'checkout_adjusted_at',\n 'adjustment_reason', 'adjustment_reason',\n 'adjusted_by_user_id', 'adjusted_by_user_id',\n 'adjusted_by_name', 'adjusted_by_name',\n -- Derivadas e n\u00E3o portadas: `manually_adjusted` \u00E9 a exist\u00EAncia de um ajuste,\n -- `adjusted_by`/`adjusted_at` s\u00E3o redundantes com a linha do ajuste, e\n -- `outside_schedule` \u00E9 o resultado do cruzamento \u2014 que no V1 \u00E9 `true` em 325\n -- de 325 registros por um defeito de configura\u00E7\u00E3o, e que no V2 \u00E9 a coluna\n -- `outside_schedule` da view v_workforce_day, calculada e n\u00E3o guardada.\n 'manually_adjusted', '-',\n 'adjusted_by', '-',\n 'adjusted_at', '-',\n 'outside_schedule', 'jsonb:v1.outside_schedule',\n 'notes', 'notes',\n -- N\u00C3O vira FK: no V1 aponta para a tabela de t\u00EDtulos do V1, cuja contraparte\n -- no V2 n\u00E3o \u00E9 a mesma coisa (mesma recusa da 046). A refer\u00EAncia crua \u00E9 o que\n -- TRAVA o ajuste da linha migrada \u2014 a trigger da se\u00E7\u00E3o 4 l\u00EA exatamente aqui.\n 'payable_invoice_id', 'jsonb:v1.payable_invoice_id'\n ),\n 'A batida. Uma linha do V1 (par entrada/sa\u00EDda + colunas de ajuste) vira at\u00E9 4 do V2: dois eventos append-only e duas linhas de ajuste. checkout_at nulo atravessa como aus\u00EAncia de evento \u2014 \u00E9 o ponto em aberto, 20 deles no tenant medido.'\n);\n\nSELECT migration._allow(\n 'schedule_templates', 'public.plg_workforce_schedule_templates', 'workforce',\n 'wf_schedule_template', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'name', 'name',\n 'active', 'is_active'\n ),\n 'O molde da escala. Pr\u00E9-requisito duro: sem template o V1 n\u00E3o deixa criar escala nenhuma.'\n);\n\nSELECT migration._allow(\n 'schedule_template_shifts', 'public.plg_workforce_schedule_shifts', 'workforce',\n 'wf_schedule_shift', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'template_id', 'template_id@schedule_templates',\n 'label', 'label',\n 'start_time', 'start_time',\n 'end_time', 'end_time',\n 'weekdays', 'weekdays',\n 'sort_order', 'sort_order'\n ),\n 'O turno do molde. weekdays \u00E9 0..6 (domingo..s\u00E1bado); qualquer valor fora disso \u00E9 quarentena, porque um turno que nunca acontece some sem erro.'\n);\n\nSELECT migration._allow(\n 'schedule_template_requirements', 'public.plg_workforce_schedule_requirements', 'workforce',\n 'wf_schedule_requirement', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'shift_id', 'shift_id@schedule_template_shifts',\n 'profession_name', 'profession_name',\n 'required_count', 'required_count'\n ),\n 'A lota\u00E7\u00E3o da fun\u00E7\u00E3o no turno \u2014 o \"(1/1)\" da tela, e o denominador que transforma posi\u00E7\u00E3o em titular/stand-by.'\n);\n\nSELECT migration._allow(\n 'schedule_template_assignments', 'public.plg_workforce_schedule_template_assignments', 'workforce',\n 'wf_schedule_template_assignment', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'template_id', 'template_id@schedule_templates',\n 'shift_id', 'shift_id@schedule_template_shifts',\n 'profession_name', 'profession_name',\n 'professional_id', 'jsonb:v1.person_id',\n 'priority', 'priority'\n ),\n 'A aloca\u00E7\u00E3o do molde. No V1 titular \u00D7 stand-by \u00E9 POSICIONAL (os primeiros required_count por priority) e vive s\u00F3 no c\u00F3digo da tela; o writer faz a conta uma vez e grava em `role`.'\n);\n\nSELECT migration._allow(\n 'schedules', 'public.plg_workforce_schedules', 'workforce',\n 'wf_schedule', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'unit_id', 'unit_id@companies:app.units',\n 'schedule_date', 'schedule_date',\n 'template_id', 'template_id@schedule_templates',\n 'status', 'status',\n 'published_at', 'published_at',\n 'is_special_day', 'is_special_day',\n 'special_day_label', 'special_day_label',\n 'notes', 'notes'\n ),\n 'A escala do dia. Hom\u00F4nima da `public.schedules` do V2, que \u00E9 OUTRA coisa (a oferta de hor\u00E1rio do spine) \u2014 \u00E9 a raz\u00E3o do prefixo plg_workforce_.'\n);\n\nSELECT migration._allow(\n 'schedule_assignments', 'public.plg_workforce_schedule_assignments', 'workforce',\n 'wf_schedule_assignment', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'schedule_id', 'schedule_id@schedules',\n 'shift_id', 'shift_id@schedule_template_shifts',\n -- O SNAPSHOT do V1 atravessa como snapshot. Reconstru\u00ED-lo a partir do turno\n -- atual do molde reescreveria a escala de anteontem quando o molde mudasse.\n 'shift_label', 'shift_label',\n 'shift_start_time', 'shift_start_time',\n 'shift_end_time', 'shift_end_time',\n 'profession_name', 'profession_name',\n 'professional_id', 'jsonb:v1.person_id',\n 'role', 'role',\n 'priority', 'priority',\n 'status', 'status',\n 'response_token', 'response_token',\n 'response_channel', 'response_channel',\n 'notified_at', 'notified_at',\n 'responded_at', 'responded_at'\n ),\n 'A atribui\u00E7\u00E3o do dia. Stand-by n\u00E3o \u00E9 folga: \u00E9 o substituto acion\u00E1vel (\"Substitutos em caso de falta\"), e confundir os dois apagaria a fila de quem cobre a falta.'\n);\n\nSELECT migration._allow(\n 'schedule_assignment_logs', 'public.plg_workforce_assignment_logs', 'workforce',\n 'wf_assignment_log', '{}'::jsonb, ARRAY[]::text[],\n jsonb_build_object(\n 'uuid', 'id',\n 'assignment_id', 'assignment_id@schedule_assignments',\n 'from_status', 'from_status',\n 'to_status', 'to_status',\n 'channel', 'channel',\n 'note', 'note',\n 'changed_by_user_id','changed_by_user_id',\n 'created_at', 'created_at'\n ),\n 'O hist\u00F3rico da atribui\u00E7\u00E3o: a \u00FAnica parte do modelo de escala do V1 que j\u00E1 nasceu append-only, e que atravessa como \u00E9.'\n);\n\n-- As duas que N\u00C3O atravessam, e a raz\u00E3o escrita onde a pr\u00F3xima onda vai\n-- procurar. \"Grid\" aqui \u00E9 grade de disponibilidade de AGENDAMENTO \u2014 quando um\n-- hor\u00E1rio pode ser marcado \u2014, n\u00E3o escala de trabalho. Port\u00E1-las para dentro do\n-- RH criaria a terceira tabela de hor\u00E1rio do cluster e colocaria a agenda dentro\n-- do plugin que o D3 mandou justamente separar dela.\n-- `WHERE tenant_id IS NULL` no \u00E1rbitro: a 073 da espinha trocou a PK de\n-- `migration.excluded_tables` por dois \u00EDndices \u00FAnicos PARCIAIS \u2014 um global e\n-- um por inquilino \u2014, porque \"n\u00E3o trazer esta tabela\" passou a ser decis\u00E3o de\n-- cliente e n\u00E3o do cluster. \u00CDndice parcial s\u00F3 serve de \u00E1rbitro de ON CONFLICT\n-- quando o comando repete o mesmo recorte. Esta decis\u00E3o \u00E9 global.\nINSERT INTO migration.excluded_tables (source_table, reason) VALUES\n ('schedule_grids',\n 'Apesar do nome, \u00E9 grade de disponibilidade de AGENDAMENTO (quando um hor\u00E1rio pode ser marcado), n\u00E3o escala de trabalho. O destino natural \u00E9 a agenda (public.schedules, kind working_hours), n\u00E3o o RH.'),\n ('schedule_grid_blocks',\n 'Bloco da grade de agendamento \u2014 mesma raz\u00E3o de schedule_grids. Escala de trabalho \u00E9 plg_workforce_schedule_*.')\nON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 10. A amarra\u00E7\u00E3o com o financeiro, quando o financeiro existe\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- Hora de trabalho vira dinheiro, e o V2 j\u00E1 tem onde: `plg_financial_invoices`\n-- para o t\u00EDtulo, `plg_financial_chart_of_accounts` e `plg_financial_cost_centers`\n-- para a classifica\u00E7\u00E3o. O que a jornada N\u00C3O pode fazer \u00E9 exigir o financeiro:\n-- o plugin \u00E9 instal\u00E1vel sozinho, e uma FK inline recusaria o arquivo inteiro num\n-- cluster que n\u00E3o ligou o m\u00F3dulo. Declarar a FK aqui, condicionada ao destino,\n-- \u00E9 o que d\u00E1 integridade onde ela \u00E9 poss\u00EDvel sem transformar prefer\u00EAncia em\n-- depend\u00EAncia.\n--\n-- Todas ON DELETE SET NULL: apagar um centro de custo n\u00E3o pode apagar a batida\n-- de ningu\u00E9m. O v\u00EDnculo some, a hora fica.\nDO $$\nDECLARE\n r record;\nBEGIN\n FOR r IN\n SELECT * FROM (VALUES\n ('plg_workforce_time_entries', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_time_entries', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_time_entries', 'payable_invoice_id', 'plg_financial_invoices'),\n ('plg_workforce_schedule_shifts', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_schedule_shifts', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_periods', 'chart_account_id', 'plg_financial_chart_of_accounts'),\n ('plg_workforce_periods', 'cost_center_id', 'plg_financial_cost_centers'),\n ('plg_workforce_periods', 'payable_invoice_id', 'plg_financial_invoices')\n ) AS t(tbl, col, target)\n LOOP\n CONTINUE WHEN to_regclass('public.' || r.target) IS NULL;\n CONTINUE WHEN EXISTS (SELECT 1 FROM pg_constraint\n WHERE conname = r.tbl || '_' || r.col || '_fkey'\n AND conrelid = ('public.' || r.tbl)::regclass);\n EXECUTE format(\n 'ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES public.%I(id) ON DELETE SET NULL',\n r.tbl, r.tbl || '_' || r.col || '_fkey', r.col, r.target);\n END LOOP;\nEND $$;\n\nSELECT set_config('migration.allowlist_unlock', 'off', true);\n";
|
|
2
|
+
export declare const MIGRATION_001_A_PROVA_DA_BATIDA_TEM_BYTES = "-- ---------------------------------------------------------------------------\n-- 001_a_prova_da_batida_tem_bytes.sql \u2014 a foto do ponto atravessa, e n\u00E3o s\u00F3 o\n-- caminho dela.\n--\n-- A 000 escreveu, no coment\u00E1rio de `evidence`:\n--\n-- \"O que n\u00E3o pode \u00E9 perder: no V1 s\u00E3o `photo_path`, `lat`, `lng`,\n-- `accuracy_m`, `ip`, `user_agent` e `face_score`.\"\n--\n-- Sete campos. Seis atravessaram. O s\u00E9timo \u2014 o \u00FAnico que uma pessoa reconhece \u2014\n-- atravessou como TEXTO: `8/2026-07-05/professional-15-1783278419303.jpg`,\n-- que \u00E9 um objeto num bucket PRIVADO de outro projeto Supabase, o do V1.\n--\n-- Medido em 18/09/2026 no tenant Pertinho do C\u00E9u: 1.320 das 1.361 batidas t\u00EAm\n-- esse caminho preenchido, e nenhuma das 1.320 imagens \u00E9 alcan\u00E7\u00E1vel daqui. No\n-- dia em que o V1 sair do ar elas deixam de existir em qualquer lugar.\n--\n-- \u00C9 pior que uma coluna vazia, porque parece cheia. `evidence.photo_path` diz\n-- que houve captura e sugere que a captura est\u00E1 guardada. A tela de Registros de\n-- Ponto, por isso mesmo, escondia a se\u00E7\u00E3o de evid\u00EAncia inteira \u2014 o coment\u00E1rio\n-- dela dizia que a evid\u00EAncia estava \"hoje vazia\", o que j\u00E1 n\u00E3o era verdade.\n--\n-- Este arquivo declara para o motor de arquivos da 054 (`migration.asset_kinds`)\n-- que a foto de batida \u00E9 uma esp\u00E9cie com bytes para transportar: de que bucket\n-- ela sai, para qual vai, o que falta atravessar, e quem reescreve o ponteiro.\n-- Mover os bytes \u00E9 do fayz-etl (`fayz_etl/blobs.py`); a regra \u00E9 daqui.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- \u00A71 O bucket, PRIVADO\n--\n-- N\u00E3o \u00E9 escolha de estilo: \u00E9 biometria facial de um funcion\u00E1rio, com hora e\n-- coordenada ao lado. O V1 j\u00E1 a guarda em bucket privado e serve por URL\n-- assinada de curta dura\u00E7\u00E3o (edge function `time-clock-photo-url`), e o V2 n\u00E3o\n-- pode rebaixar isso \u2014 um bucket p\u00FAblico transformaria \"a cara e o hor\u00E1rio de\n-- quem trabalha aqui\" em algo que basta um caminho para ver.\n--\n-- A leitura \u00E9 por pasta de tenant, o mesmo escopo que o resto da plataforma usa.\n-- Com ele, `createSignedUrl` do lado do app funciona para quem \u00E9 do tenant e\n-- falha para quem n\u00E3o \u00E9, sem RPC no meio.\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nINSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)\nVALUES ('workforce-punch-photos', 'workforce-punch-photos', false, 5242880,\n ARRAY['image/jpeg', 'image/png', 'image/webp'])\nON CONFLICT (id) DO UPDATE SET\n public = EXCLUDED.public,\n file_size_limit = EXCLUDED.file_size_limit,\n allowed_mime_types = EXCLUDED.allowed_mime_types;\n\nDROP POLICY IF EXISTS plg_workforce_punch_photos_read ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_insert ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_update ON storage.objects;\nDROP POLICY IF EXISTS plg_workforce_punch_photos_delete ON storage.objects;\n\n-- Sem `anon`. A prova de uma jornada n\u00E3o se l\u00EA deslogado.\nCREATE POLICY plg_workforce_punch_photos_read ON storage.objects\n FOR SELECT TO authenticated\n USING (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_workforce_punch_photos_insert ON storage.objects\n FOR INSERT TO authenticated\n WITH CHECK (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_workforce_punch_photos_update ON storage.objects\n FOR UPDATE TO authenticated\n USING (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n )\n WITH CHECK (\n bucket_id = 'workforce-punch-photos'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\n-- DELETE fica de fora de prop\u00F3sito: apagar a foto de uma batida \u00E9 apagar prova.\n-- Quem precisar (pedido de exclus\u00E3o LGPD) passa pelo `service_role`, que \u00E9 um\n-- ato deliberado e registrado, n\u00E3o um clique.\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- \u00A72 A esp\u00E9cie\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nSELECT public.register_kind('document', 'punch_photo', 'workforce', ARRAY['active'], NULL,\n 'A foto que o totem capturou na batida \u2014 prova de quem bateu o ponto, junto com GPS e score facial');\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- \u00A73 O que falta, e o ponteiro\n--\n-- Todo o resto deste arquivo depende da 054 do base. Num pool que ainda n\u00E3o a\n-- aplicou, o plugin instala sem o transporte de bytes em vez de recusar a\n-- instala\u00E7\u00E3o: o ponto funciona sem foto, e a foto sem o ponto n\u00E3o \u00E9 nada.\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nDO $do$\nBEGIN\nIF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN\n RAISE NOTICE 'plugin-workforce 001: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';\n RETURN;\nEND IF;\n\nCREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS\nSELECT e.tenant_id,\n 'time_entry'::text AS subject_type,\n e.id AS subject_id,\n e.person_id,\n e.evidence ->> 'photo_path' AS source_path,\n -- O bucket do V1 \u00E9 privado: n\u00E3o h\u00E1 URL para baixar sem chave, e o\n -- transporte usa a chave de servi\u00E7o da origem.\n NULL::text AS source_url,\n -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no\n -- nome e n\u00E3o \u00E9 reconstru\u00EDvel a partir do destino; o do V2 \u00E9 deriv\u00E1vel de\n -- qualquer lado, o que \u00E9 o que faz o rerun sobrescrever em vez de dobrar.\n e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,\n coalesce(e.person_name, 'sem nome') || ' \u00B7 '\n || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title\n FROM public.plg_workforce_time_entries e\n -- `photo_path` \u00E9 a proced\u00EAncia (o caminho no V1, que fica); `photo` \u00E9 o\n -- ponteiro do V2. Ter o primeiro e n\u00E3o o segundo \u00C9 a defini\u00E7\u00E3o de \"falta\n -- atravessar\", e escrever o segundo tira a linha desta lista.\n WHERE e.evidence ? 'photo_path'\n AND NOT (e.evidence ? 'photo');\n\nCOMMENT ON VIEW migration.v_asset_backlog_punch_photo IS\n 'Batidas cuja foto ainda s\u00F3 existe no bucket privado do V1 (001). A condi\u00E7\u00E3o \u00C9 o ponteiro: escrever evidence.photo apaga a linha desta lista.';\n\nCREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nDECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;\nBEGIN\n IF v_entry IS NULL THEN\n RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';\n END IF;\n -- UPDATE numa tabela append-only: passa porque `attach_asset` liga\n -- `migration.writer`, que \u00E9 a mesma escotilha que o writer da batida usa.\n -- `photo_path` N\u00C3O \u00E9 tocado \u2014 ele \u00E9 de onde a imagem veio, e continua sendo.\n UPDATE public.plg_workforce_time_entries\n SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],\n jsonb_build_object(\n 'bucket', p_row ->> 'bucket',\n 'path', p_row ->> 'target_path',\n 'document_id', p_document)),\n updated_at = now()\n WHERE id = v_entry AND tenant_id = p_tenant;\nEND $fn$;\n\nPERFORM migration.register_asset_kind(\n 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',\n 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',\n 'A foto do totem. Bucket privado nas duas pontas: \u00E9 biometria facial com hora e coordenada ao lado, e o V1 j\u00E1 a tratava assim.');\nEND $do$;\n";
|
|
3
|
+
export declare const MIGRATION_002_O_GUARDA_DA_FOTO_OLHAVA_O_NOME_ERRADO = "-- ---------------------------------------------------------------------------\n-- 002_o_guarda_da_foto_olhava_o_nome_errado.sql \u2014 o \u00A73 da 001 nunca rodou.\n--\n-- A 001 protege o pr\u00F3prio corpo com um guard, porque tudo nela depois do \u00A72\n-- depende da 054 do base:\n--\n-- IF to_regproc('migration.register_asset_kind(text,text,text,text,text,text)')\n-- IS NULL THEN RETURN; END IF;\n--\n-- **`to_regproc` recebe um NOME, n\u00E3o uma assinatura.** Com a lista de tipos\n-- entre par\u00EAnteses ela devolve NULL \u2014 e n\u00E3o levanta erro, que \u00E9 exatamente o que\n-- torna isso invis\u00EDvel: `to_regproc` foi feita para responder \"n\u00E3o existe\" em vez\n-- de estourar. Quem l\u00EA a linha l\u00EA uma checagem; o Postgres l\u00EA uma pergunta\n-- malformada e responde \"n\u00E3o existe\" \u00E0s duas.\n--\n-- Resultado medido no cluster em 19/09/2026, logo depois de aplicar a 001: o\n-- bucket `workforce-punch-photos` existe (\u00A71, fora do bloco), a esp\u00E9cie\n-- `punch_photo` est\u00E1 registrada (\u00A72, fora do bloco), e do \u00A73 n\u00E3o existe NADA \u2014\n-- nem a view de backlog, nem `_point_punch_photo`, nem a linha em\n-- `migration.asset_kinds`. A migration \"passou\". A foto continuou sem\n-- transporte, e o `mig-assets` teria relatado s\u00F3 os 63 avatares como se fosse o\n-- backlog inteiro.\n--\n-- Quem quer assinatura \u00E9 `to_regprocedure`. \u00C9 a \u00FAnica diferen\u00E7a deste arquivo.\n--\n-- \u2500\u2500 Por que um arquivo novo, e n\u00E3o uma corre\u00E7\u00E3o na 001 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- A 001 j\u00E1 est\u00E1 no ledger deste cluster. `fayz db apply` trata checksum\n-- diferente num arquivo aplicado como PARADA \u2014 e est\u00E1 certo: um app que j\u00E1 rodou\n-- a 001 n\u00E3o pode descobrir que os bytes dela mudaram. Corrigir para frente custa\n-- este arquivo; editar a 001 custaria o apply de todo app que j\u00E1 a tomou.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nDO $do$\nBEGIN\n-- `to_regprocedure`, que \u00E9 quem entende `(text,text,\u2026)`. A mesma armadilha est\u00E1\n-- na 152 do base (`to_regproc('app.scaffold_table(text,text,text,boolean,text)')`),\n-- e \u00E9 por isso que a checagem aqui \u00E9 pelo NOME quando basta o nome: um guard que\n-- mente \u00E9 pior que nenhum guard, porque o arquivo sai dizendo que aplicou.\nIF to_regprocedure('migration.register_asset_kind(text,text,text,text,text,text)') IS NULL THEN\n RAISE NOTICE 'plugin-workforce 002: migration.register_asset_kind ausente (base < 054); a foto da batida fica sem transporte';\n RETURN;\nEND IF;\n\nCREATE OR REPLACE VIEW migration.v_asset_backlog_punch_photo AS\nSELECT e.tenant_id,\n 'time_entry'::text AS subject_type,\n e.id AS subject_id,\n e.person_id,\n e.evidence ->> 'photo_path' AS source_path,\n -- O bucket do V1 \u00E9 privado: n\u00E3o h\u00E1 URL para baixar sem chave, e o\n -- transporte usa a chave de servi\u00E7o da origem.\n NULL::text AS source_url,\n -- Uma foto por batida, nomeada pela batida. O caminho do V1 tem epoch no\n -- nome e n\u00E3o \u00E9 reconstru\u00EDvel a partir do destino; o do V2 \u00E9 deriv\u00E1vel de\n -- qualquer lado, o que \u00E9 o que faz o rerun sobrescrever em vez de dobrar.\n e.tenant_id::text || '/' || e.id::text || '.jpg' AS target_path,\n coalesce(e.person_name, 'sem nome') || ' \u00B7 '\n || to_char(e.punched_at, 'DD/MM/YYYY HH24:MI') AS title\n FROM public.plg_workforce_time_entries e\n -- `photo_path` \u00E9 a proced\u00EAncia (o caminho no V1, que fica); `photo` \u00E9 o\n -- ponteiro do V2. Ter o primeiro e n\u00E3o o segundo \u00C9 a defini\u00E7\u00E3o de \"falta\n -- atravessar\", e escrever o segundo tira a linha desta lista.\n WHERE e.evidence ? 'photo_path'\n AND NOT (e.evidence ? 'photo');\n\nCOMMENT ON VIEW migration.v_asset_backlog_punch_photo IS\n 'Batidas cuja foto ainda s\u00F3 existe no bucket privado do V1 (001/002). A condi\u00E7\u00E3o \u00C9 o ponteiro: escrever evidence.photo apaga a linha desta lista.';\n\nCREATE OR REPLACE FUNCTION migration._point_punch_photo(p_tenant uuid, p_document uuid, p_row jsonb)\nRETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $fn$\nDECLARE v_entry uuid := (p_row ->> 'subject_id')::uuid;\nBEGIN\n IF v_entry IS NULL THEN\n RAISE EXCEPTION '_point_punch_photo: preciso de subject_id (a batida)' USING ERRCODE = '22023';\n END IF;\n -- UPDATE numa tabela append-only: passa porque `attach_asset` liga\n -- `migration.writer`, que \u00E9 a mesma escotilha que o writer da batida usa.\n -- `photo_path` N\u00C3O \u00E9 tocado \u2014 ele \u00E9 de onde a imagem veio, e continua sendo.\n UPDATE public.plg_workforce_time_entries\n SET evidence = migration._deep_set(coalesce(evidence, '{}'::jsonb), ARRAY['photo'],\n jsonb_build_object(\n 'bucket', p_row ->> 'bucket',\n 'path', p_row ->> 'target_path',\n 'document_id', p_document)),\n updated_at = now()\n WHERE id = v_entry AND tenant_id = p_tenant;\nEND $fn$;\n\nPERFORM migration.register_asset_kind(\n 'punch_photo', 'time-clock-photos', 'workforce-punch-photos',\n 'v_asset_backlog_punch_photo', 'migration._point_punch_photo',\n 'A foto do totem. Bucket privado nas duas pontas: \u00E9 biometria facial com hora e coordenada ao lado, e o V1 j\u00E1 a tratava assim.');\nEND $do$;\n";
|
|
4
|
+
export declare const MIGRATION_003_O_PONTO_EM_ABERTO_PARA_DE_MENTIR_A_DURACAO = "-- ---------------------------------------------------------------------------\n-- 003_o_ponto_em_aberto_para_de_mentir_a_duracao.sql \u2014 `-497155h02`.\n--\n-- A tela de Registros de Ponto mostra isso na coluna Dura\u00E7\u00E3o de todo ponto em\n-- aberto. Medido em 19/09/2026 no tenant Pertinho do C\u00E9u: **19 dias negativos,\n-- e os 19 est\u00E3o abertos** \u2014 nenhum dia fechado d\u00E1 negativo. O n\u00FAmero n\u00E3o \u00E9\n-- aleat\u00F3rio e n\u00E3o \u00E9 overflow.\n--\n-- \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\n--\n-- A 000 soma as horas com sinal, e o desenho \u00E9 bom: `clock_out` e `break_start`\n-- FECHAM um trecho e entram positivos, `clock_in` e `break_end` ABREM e entram\n-- negativos. Quantos intervalos o dia tiver, a conta \u00E9 a mesma \u2014 \u00E9 isso que o\n-- par \u00FAnico do V1 n\u00E3o consegue fazer.\n--\n-- Ela s\u00F3 assume uma coisa que o dia em aberto n\u00E3o cumpre: que toda abertura tem\n-- o seu fechamento. Um dia com uma batida s\u00F3 \u2014\n--\n-- clock_in 18:09\n--\n-- \u2014 soma `-extract(epoch FROM '2026-09-18 18:09')`, que \u00E9 \u22121.789.790. \u2060 segundos\n-- desde 1970. Dividido por 60 vira \u221229.829.302 minutos, e o formatador de horas\n-- faz o resto: `-497155h02`. O epoch vazou para a tela.\n--\n-- \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\n--\n-- Devolver \u00E0 soma a abertura que ficou sem par. O que sobra \u00E9 exatamente a\n-- soma dos trechos FECHADOS, que \u00E9 a \u00FAnica coisa que se pode afirmar de um dia\n-- que ainda n\u00E3o terminou:\n--\n-- entrada 09:00, sa\u00EDda 12:00, entrada 13:00 \u2192 3h00 fechadas, aberto desde 13:00\n-- entrada 18:09 \u2192 0h00 fechadas, aberto desde 18:09\n--\n-- Zero n\u00E3o \u00E9 um defeito aqui: \u00E9 a resposta certa para \"quanto j\u00E1 fechou?\" antes\n-- de qualquer fechamento. O que faltava para a tela era a OUTRA metade da\n-- pergunta, e \u00E9 a coluna nova.\n--\n-- \u2500\u2500 `open_since`, e por que uma coluna e n\u00E3o uma conta na tela \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Quem confere ponto quer saber h\u00E1 quanto tempo a pessoa est\u00E1 dentro. A tela\n-- n\u00E3o consegue descobrir isso sozinha: ela recebe o dia agregado, n\u00E3o as\n-- batidas, e \"a \u00FAltima abertura sem par\" \u00E9 justamente o que a agrega\u00E7\u00E3o sabe e\n-- ela n\u00E3o. Sem a coluna, a alternativa seria a tela buscar as batidas de cada\n-- linha \u2014 299 consultas para desenhar uma tabela.\n--\n-- O rel\u00F3gio corre do lado de quem olha, n\u00E3o do banco: a coluna \u00E9 o INSTANTE, e\n-- o \"6h58 em curso\" \u00E9 calculado no render. Um intervalo calculado aqui\n-- envelheceria entre a consulta e a pintura, e envelheceria de novo a cada\n-- segundo que a aba ficasse aberta.\n--\n-- \u2500\u2500 O que N\u00C3O 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\n--\n-- Dia em aberto continua **fora** do total do dia e do per\u00EDodo. A regra \u00E9 a do\n-- V1 e est\u00E1 certa: contar uma jornada que n\u00E3o terminou inventa hora que ningu\u00E9m\n-- trabalhou. O que muda \u00E9 que agora o n\u00FAmero que ele n\u00E3o soma tamb\u00E9m n\u00E3o \u00E9\n-- absurdo quando algu\u00E9m olha a linha.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n -- A \u00FAltima ABERTURA do dia. Quando as contagens n\u00E3o batem, \u00E9 ela que\n -- ficou sem par \u2014 os eventos de um dia chegam em ordem, e uma abertura\n -- anterior j\u00E1 teria sido fechada pela seguinte.\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id,\n b.unit_id,\n b.person_id,\n b.work_date,\n b.first_in,\n b.last_out,\n b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve \u00E0 soma a abertura sem par. Sem isto o que sobra \u00E9 o epoch\n -- dela, com sinal trocado, e foi o que a tela vinha mostrando.\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia \u00E9 normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n -- Coluna nova, no fim: `CREATE OR REPLACE VIEW` s\u00F3 aceita acrescentar\n -- aqui, e \u00E9 de prop\u00F3sito \u2014 quem l\u00EA por posi\u00E7\u00E3o n\u00E3o se move.\n r.open_since\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista \u00D7 realizada por pessoa e dia. Previsto vem da escala publicada, realizado das batidas com ajuste aplicado. FULL OUTER JOIN: `absent` \u00E9 escala sem ponto, `outside_schedule` \u00E9 ponto sem escala. `worked_minutes` conta s\u00F3 os trechos FECHADOS; `open_since` \u00E9 a abertura que ainda n\u00E3o tem par (003).';\n\nCOMMENT ON COLUMN public.v_workforce_day.open_since IS\n 'O instante da abertura sem par, quando o ponto est\u00E1 aberto (003). \u00C9 o INSTANTE e n\u00E3o a dura\u00E7\u00E3o: o rel\u00F3gio corre do lado de quem olha a tela, e um intervalo calculado aqui envelheceria entre a consulta e a pintura.';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n";
|
|
5
|
+
export declare const MIGRATION_004_A_JORNADA_IMPLAUSIVEL_NAO_SOMA = "-- ---------------------------------------------------------------------------\n-- 004_a_jornada_implausivel_nao_soma.sql \u2014 45h45 num dia.\n--\n-- Medido em 19/09/2026 no tenant Pertinho do C\u00E9u, na tela de Registros de Ponto:\n--\n-- Steffany Fidelis da Costa 15:41 \u2192 13:26 45h45\n-- CRISTIANE CARDOSO OLIVEIRA 17:14 \u2192 09:23 16h10\n-- jannyne gomes dos santos 09:07 \u2192 08:58 15h56\n--\n-- e o cabe\u00E7alho do dia somando **109h45** para oito pessoas.\n--\n-- Nenhum desses \u00E9 erro de conta: a 003 j\u00E1 consertou a soma, e esses dias est\u00E3o\n-- FECHADOS. Eles s\u00E3o a medi\u00E7\u00E3o correta de um par errado \u2014 algu\u00E9m entrou, foi\n-- embora sem bater a sa\u00EDda, e a sa\u00EDda caiu horas ou dias depois. O V1 grava\n-- `checkin_at` e `checkout_at` como um par s\u00F3, ent\u00E3o o buraco atravessou inteiro.\n--\n-- \u2500\u2500 Por que n\u00E3o 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\n--\n-- Trocar 45h45 por 8h faria o total parecer certo. \u00C9 o pior resultado poss\u00EDvel:\n--\n-- \u00B7 8h ali \u00E9 um n\u00FAmero que ningu\u00E9m trabalhou e ningu\u00E9m digitou \u2014 \u00E9 a regra\n-- \"nada de default inventado\" quebrada no lugar onde ela mais custa, que \u00E9\n-- base de c\u00E1lculo de folha;\n-- \u00B7 e o dia deixaria de parecer estranho, ent\u00E3o a batida furada nunca seria\n-- corrigida. O erro sai da tela e fica no dado, que \u00E9 o contr\u00E1rio de\n-- resolver.\n--\n-- \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\n--\n-- Estende a regra que a tela J\u00C1 tem. Ponto em aberto n\u00E3o soma, porque contar\n-- uma jornada que n\u00E3o terminou inventa hora. Um dia de 45h \u00E9 o mesmo problema\n-- com outra cara \u2014 um par em que n\u00E3o se pode confiar \u2014, ent\u00E3o ele tamb\u00E9m n\u00E3o\n-- soma. O valor medido continua \u00E0 vista, inteiro, com o motivo escrito ao lado.\n--\n-- `review_reason` nomeia por que o dia n\u00E3o entra na conta:\n--\n-- 'open' ainda n\u00E3o terminou\n-- 'span_too_long' fechou, e fechou com dura\u00E7\u00E3o que n\u00E3o \u00E9 uma jornada\n--\n-- NULL \u00E9 o dia que conta. Uma coluna s\u00F3, e quem soma filtra por ela \u2014 em vez de\n-- cada relat\u00F3rio redescobrir a regra e descobrir diferente.\n--\n-- \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\n--\n-- A jornada mais longa do vocabul\u00E1rio do V1 \u00E9 a escala 12x36: doze horas. Com\n-- duas de extra, quatorze. Dezesseis deixa margem para o turno que vira a noite\n-- e ainda separa com folga o caso real, que n\u00E3o \u00E9 uma jornada comprida e sim\n-- uma sa\u00EDda que ficou pendurada \u2014 os tr\u00EAs medidos acima t\u00EAm 16h, 16h e 46h.\n--\n-- \u00C9 o MESMO dezesseis que a tela usa para dizer \"sem sa\u00EDda h\u00E1 31h\" num ponto\n-- aberto, e \u00E9 de prop\u00F3sito: um n\u00FAmero, uma justificativa, os dois lados da\n-- mesma pergunta.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.workforce_max_shift_minutes()\nRETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE\nAS $$ SELECT 16 * 60 $$;\n\nCOMMENT ON FUNCTION public.workforce_max_shift_minutes() IS\n 'Acima disto uma jornada fechada deixa de ser jornada e vira sa\u00EDda n\u00E3o batida (004). 12x36 \u00E9 a mais longa do vocabul\u00E1rio do V1; dezesseis d\u00E1 margem ao turno que vira a noite. Fun\u00E7\u00E3o e n\u00E3o constante literal para que a tela, a view e um relat\u00F3rio futuro citem o mesmo n\u00FAmero.';\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id,\n e.unit_id,\n e.person_id,\n e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n -- A \u00FAltima ABERTURA do dia. Quando as contagens n\u00E3o batem, \u00E9 ela que\n -- ficou sem par \u2014 os eventos de um dia chegam em ordem, e uma abertura\n -- anterior j\u00E1 teria sido fechada pela seguinte.\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id,\n b.unit_id,\n b.person_id,\n b.work_date,\n b.first_in,\n b.last_out,\n b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve \u00E0 soma a abertura sem par (003). Sem isto o que sobra \u00E9 o\n -- epoch dela, com sinal trocado.\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id,\n a.unit_id,\n a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id,\n a.role,\n a.status AS assignment_status,\n a.shift_label,\n a.shift_start_time,\n a.shift_end_time,\n -- O turno que vira o dia \u00E9 normal aqui; sem o +24h a jornada da noite\n -- daria negativa.\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n -- O MEDIDO, sempre. N\u00E3o \u00E9 aqui que se decide se ele conta \u2014 a coluna\n -- mente se for corrigida, e um n\u00FAmero corrigido em sil\u00EAncio \u00E9 pior que\n -- um n\u00FAmero estranho.\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n r.open_since,\n -- Por que este dia n\u00E3o entra na soma. NULL = entra.\n CASE\n WHEN r.person_id IS NULL THEN NULL -- escala sem ponto: n\u00E3o h\u00E1 o que somar\n WHEN r.is_open THEN 'open'\n WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()\n THEN 'span_too_long'\n END AS review_reason\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON VIEW public.v_workforce_day IS\n 'Jornada prevista \u00D7 realizada por pessoa e dia. `worked_minutes` \u00E9 sempre o MEDIDO; `review_reason` diz por que um dia n\u00E3o entra na soma \u2014 `open` (n\u00E3o terminou) ou `span_too_long` (fechou com dura\u00E7\u00E3o que n\u00E3o \u00E9 jornada, 004). Quem soma filtra por ela.';\n\nCOMMENT ON COLUMN public.v_workforce_day.review_reason IS\n 'NULL = o dia conta. `open` = ponto em aberto. `span_too_long` = acima de workforce_max_shift_minutes(), que na pr\u00E1tica \u00E9 sa\u00EDda n\u00E3o batida e n\u00E3o jornada longa (004).';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n";
|
|
6
|
+
export declare const MIGRATION_005_A_FICHA_TRABALHISTA_TEM_ONDE_MORAR = "-- ---------------------------------------------------------------------------\n-- 005_a_ficha_trabalhista_tem_onde_morar.sql \u2014 o RH do V1 ganha destino.\n--\n-- Escrito a partir de `~/dev/beautyplace/docs/v1-guide/rh`, que documenta o\n-- m\u00F3dulo do V1 andando nele: a Vis\u00E3o Geral (oito cart\u00F5es, tr\u00EAs listas) e a Ficha\n-- de RH da pessoa (dezesseis vistas). O FLOW de cada um diz `v2: status:\n-- missing` \u2014 e dizia certo: n\u00E3o havia nada disso aqui.\n--\n-- \u2500\u2500 O que este arquivo N\u00C3O traz, e por qu\u00EA \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- N\u00E3o s\u00E3o as dezesseis vistas. Medido na origem em 19/09/2026, nas 886 pessoas\n-- dos tr\u00EAs tenants em porte:\n--\n-- as 12 tabelas de lista da Ficha 0 linhas \u2014 no V1 INTEIRO, n\u00E3o s\u00F3\n-- (f\u00E9rias, faltas, advert\u00EAncias, nesses tenants\n-- treinamentos, avalia\u00E7\u00F5es, exames,\n-- EPIs, pend\u00EAncias, dependentes,\n-- hist\u00F3rico salarial, 13\u00BA, benef\u00EDcios)\n-- admission_date, dismissal_date, 0 preenchidos\n-- job_title, pis_pasep, CTPS, eSocial\n-- base_salary 25 de 422\n-- contract_type 2\n--\n-- O pr\u00F3prio FLOW da Vis\u00E3o Geral avisa: *\"Num tenant rec\u00E9m-migrado ele mostra\n-- headcount cheio e todo o resto zerado \u2014 e isso n\u00E3o \u00E9 defeito, \u00E9 ficha vazia.\"*\n-- Aqui TODO tenant \u00E9 assim. Desenhar dezesseis vistas para portar zero linha\n-- seria construir um formul\u00E1rio de cada vez que algu\u00E9m leu um FLOW.\n--\n-- Ent\u00E3o vem o n\u00FAcleo que sustenta o conceito e j\u00E1 \u00E9 \u00FAtil no primeiro dia:\n--\n-- employment o v\u00EDnculo \u2014 \u00E9 dele que saem headcount, admiss\u00F5es,\n-- demiss\u00F5es, turnover e experi\u00EAncia vencendo. Sem ele\n-- nenhum n\u00FAmero de RH existe, e \u00E9 a vista 1 do V1\n-- vacations f\u00E9rias. Obriga\u00E7\u00E3o legal, recorrente, e a \u00FAnica lista do\n-- V1 que alimenta um cart\u00E3o\n-- absences faltas e afastamentos\n-- pending_items pend\u00EAncias \u2014 a outra lista que aparece no painel\n--\n-- As outras doze entram quando algu\u00E9m for us\u00E1-las. O que elas precisam j\u00E1 est\u00E1\n-- de p\u00E9: a forma da tabela, o escopo, a permiss\u00E3o e a view do painel.\n--\n-- \u2500\u2500 A permiss\u00E3o, 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\n--\n-- V1-069: no V1 **n\u00E3o h\u00E1 permiss\u00E3o separada para RH**. Quem edita cadastro v\u00EA\n-- sal\u00E1rio, CPF, dependentes e CID de atestado. O FLOW da Ficha fecha dizendo que\n-- portar s\u00F3 vale \"desde que a quest\u00E3o da permiss\u00E3o separada seja resolvida\n-- antes\" \u2014 ent\u00E3o ela \u00E9 resolvida aqui, e n\u00E3o depois.\n--\n-- `workforce_hr.*` \u00E9 uma fam\u00EDlia pr\u00F3pria: quem tem `workforce.read` v\u00EA escala e\n-- ponto e N\u00C3O v\u00EA sal\u00E1rio. S\u00E3o duas perguntas diferentes e agora t\u00EAm duas\n-- respostas.\n--\n-- \u2500\u2500 O que o V2 faz e o V1 n\u00E3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- O FLOW da vista 10 registra, como defeito: *\"N\u00E3o conversa com o ponto\n-- eletr\u00F4nico nem com a escala: uma falta lan\u00E7ada aqui n\u00E3o aparece l\u00E1.\"* No V1\n-- s\u00E3o dois sistemas sobre a mesma pessoa que n\u00E3o se olham \u2014 algu\u00E9m de atestado\n-- aparece como FALTA na folha de ponto, e quem confere precisa lembrar de cabe\u00E7a.\n--\n-- A \u00A77 conserta: `v_workforce_day` passa a saber do afastamento. Um dia coberto\n-- por atestado deixa de ser \"Falta\" e passa a dizer o que foi. \u00C9 a raz\u00E3o de o RH\n-- morar no mesmo plugin do ponto, e n\u00E3o ao lado dele.\n--\n-- Idempotente e replay-safe.\n-- ---------------------------------------------------------------------------\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 1. A PERMISS\u00C3O SEPARADA (V1-069)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nINSERT INTO app.permissions (key, plugin, description) VALUES\n ('workforce_hr.read', 'workforce_hr', 'Ver a ficha trabalhista: v\u00EDnculo, sal\u00E1rio, f\u00E9rias, afastamentos e pend\u00EAncias.'),\n ('workforce_hr.create', 'workforce_hr', 'Lan\u00E7ar na ficha trabalhista.'),\n ('workforce_hr.edit', 'workforce_hr', 'Corrigir lan\u00E7amento da ficha trabalhista.'),\n ('workforce_hr.delete', 'workforce_hr', 'Excluir lan\u00E7amento da ficha trabalhista.')\nON CONFLICT (key) DO UPDATE SET plugin = EXCLUDED.plugin, description = EXCLUDED.description;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 2. O V\u00CDNCULO\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- Uma linha por pessoa, e n\u00E3o colunas em `public.people`. Tr\u00EAs raz\u00F5es, e a\n-- terceira \u00E9 a que decide:\n--\n-- \u00B7 `people` \u00E9 do core e atende cliente, fornecedor e profissional; quarenta\n-- colunas de CLT ali seriam nulas para a esmagadora maioria das linhas;\n-- \u00B7 o V1 tem DUAS tabelas (professionals, employees) com os mesmos campos, e\n-- o V2 tem uma pessoa s\u00F3 \u2014 a separa\u00E7\u00E3o \u00E9 papel, n\u00E3o cadastro;\n-- \u00B7 e \u00E9 a tabela que carrega o sal\u00E1rio. Ela precisa de uma policy PR\u00D3PRIA,\n-- e uma coluna n\u00E3o tem policy: RLS \u00E9 por tabela. Guardar sal\u00E1rio em\n-- `people` seria reproduzir o V1-069 no schema.\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_employment (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n\n -- \u2500\u2500 v\u00EDnculo (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\n -- Vocabul\u00E1rio exato do FLOW. Lista fechada porque \u00E9 ela que decide encargo, e\n -- \"CLT \" com espa\u00E7o vira um tipo de contrato novo em sil\u00EAncio.\n contract_kind text CHECK (contract_kind IN\n ('clt', 'pj', 'estagio', 'jovem_aprendiz', 'autonomo', 'socio', 'temporario', 'intermitente')),\n admission_date date,\n dismissal_date date,\n dismissal_reason text,\n -- O V1 tem os dois: o fim da PRIMEIRA experi\u00EAncia e o fim FINAL. \u00C9 o segundo\n -- que alimenta o cart\u00E3o \"Experi\u00EAncia vencendo\".\n probation_first_end date,\n probation_final_end date,\n job_title text,\n department text,\n schedule_regime text CHECK (schedule_regime IN\n ('mensalista', 'horista', 'diarista', 'escala_12x36', 'escala_6x1', 'escala_5x2')),\n weekly_hours numeric(5,2),\n monthly_hours numeric(6,2),\n esocial_registration text,\n\n -- \u2500\u2500 remunera\u00E7\u00E3o (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\n -- Valor de REFER\u00CANCIA, e o V1 diz isso com todas as letras: nada aqui vira\n -- conta a pagar, comiss\u00E3o ou desconto. O coment\u00E1rio fica porque a coluna\n -- chamada `base_salary` convida a supor o contr\u00E1rio.\n base_salary numeric(14,2),\n pay_frequency text CHECK (pay_frequency IN ('mensal', 'quinzenal', 'semanal', 'diario', 'por_hora')),\n -- Os quinze aux\u00EDlios e adicionais da vista 2 em jsonb, e n\u00E3o em quinze\n -- colunas. Medido: ZERO deles preenchido nas 886 pessoas. Quinze colunas\n -- nulas seriam quinze decis\u00F5es de schema tomadas sem um caso de uso; a chave\n -- que a casa usar aparece aqui e vira coluna quando pagar a passagem.\n allowances jsonb NOT NULL DEFAULT '{}'::jsonb,\n\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n -- Um v\u00EDnculo por pessoa. Recontrata\u00E7\u00E3o reabre o mesmo, com a data nova \u2014 o\n -- hist\u00F3rico de quem saiu e voltou \u00E9 uma vista de lista que ainda n\u00E3o existe,\n -- e inventar a segunda linha agora criaria dois headcounts para uma pessoa.\n CONSTRAINT plg_workforce_employment_person UNIQUE (tenant_id, person_id),\n -- Demiss\u00E3o antes da admiss\u00E3o n\u00E3o \u00E9 dado ruim, \u00E9 dado imposs\u00EDvel.\n CONSTRAINT plg_workforce_employment_dates\n CHECK (dismissal_date IS NULL OR admission_date IS NULL OR dismissal_date >= admission_date)\n);\n\nCOMMENT ON TABLE public.plg_workforce_employment IS\n 'O v\u00EDnculo trabalhista de uma pessoa (005). \u00C9 dele que saem headcount, admiss\u00F5es, demiss\u00F5es, turnover e experi\u00EAncia vencendo. Tabela pr\u00F3pria e n\u00E3o colunas em people: RLS \u00E9 por tabela, e \u00E9 o sal\u00E1rio que precisa da policy.';\nCOMMENT ON COLUMN public.plg_workforce_employment.base_salary IS\n 'Valor de REFER\u00CANCIA. N\u00E3o vira conta a pagar, comiss\u00E3o nem desconto \u2014 o V1 tamb\u00E9m n\u00E3o fazia isso, e o nome da coluna convida a supor que faz.';\nCOMMENT ON COLUMN public.plg_workforce_employment.allowances IS\n 'Aux\u00EDlios e adicionais (VT, VR, VA, plano, creche, noturno, insalubridade\u2026). Em jsonb porque os quinze campos da vista 2 do V1 est\u00E3o vazios nas 886 pessoas medidas: vira coluna quando algu\u00E9m pagar a passagem.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_employment_tenant_person\n ON public.plg_workforce_employment (tenant_id, person_id);\nCREATE INDEX IF NOT EXISTS plg_workforce_employment_ativo\n ON public.plg_workforce_employment (tenant_id) WHERE dismissal_date IS NULL;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 3. F\u00C9RIAS (vista 8)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_vacations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n -- O per\u00EDodo AQUISITIVO: o que a pessoa ganhou. \u00C9 o `accrual_end` que alimenta\n -- \"F\u00E9rias vencendo (60d)\".\n accrual_start date NOT NULL,\n accrual_end date NOT NULL,\n -- O per\u00EDodo de GOZO: quando ela tirou. Nulo \u00E9 o estado normal de quem ainda\n -- n\u00E3o programou.\n taken_start date,\n taken_end date,\n days_taken integer,\n days_sold integer,\n advance_13th boolean NOT NULL DEFAULT false,\n -- Digitado, como no V1. O sistema N\u00C3O calcula o ter\u00E7o \u2014 e escrever isso aqui\n -- \u00E9 o que impede algu\u00E9m de supor que a coluna vazia \u00E9 um bug.\n constitutional_third numeric(14,2),\n status text NOT NULL DEFAULT 'pendente'\n CHECK (status IN ('pendente', 'programada', 'em_gozo', 'concluida', 'vencida')),\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_vacations_accrual CHECK (accrual_end >= accrual_start),\n CONSTRAINT plg_workforce_vacations_taken\n CHECK (taken_end IS NULL OR taken_start IS NULL OR taken_end >= taken_start)\n);\n\nCOMMENT ON TABLE public.plg_workforce_vacations IS\n 'Per\u00EDodo aquisitivo e de gozo de f\u00E9rias (005). Nada \u00E9 calculado, inclusive o ter\u00E7o constitucional \u2014 \u00E9 digitado, como no V1.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_vacations_vencendo\n ON public.plg_workforce_vacations (tenant_id, accrual_end) WHERE status <> 'concluida';\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 4. FALTAS E AFASTAMENTOS (vista 10)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_absences (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n kind text NOT NULL CHECK (kind IN\n ('falta_justificada', 'falta_injustificada', 'atestado', 'licenca_maternidade',\n 'licenca_paternidade', 'licenca_nao_remunerada', 'suspensao', 'ferias', 'afastamento_inss')),\n starts_on date NOT NULL,\n -- Nulo = afastamento em aberto, que \u00E9 um estado real (INSS sem alta marcada).\n ends_on date,\n days integer,\n paid boolean NOT NULL DEFAULT true,\n -- Dado m\u00E9dico. Mora aqui e n\u00E3o em `people` pelo mesmo motivo do sal\u00E1rio: \u00E9 a\n -- policy de `workforce_hr` que o protege, e V1-069 \u00E9 exatamente sobre isto.\n icd_code text,\n doctor text,\n notes text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT plg_workforce_absences_span CHECK (ends_on IS NULL OR ends_on >= starts_on)\n);\n\nCOMMENT ON TABLE public.plg_workforce_absences IS\n 'Falta e afastamento (005). Diferente do V1, CONVERSA com o ponto: v_workforce_day l\u00EA daqui, e um dia coberto por atestado deixa de aparecer como falta.';\nCOMMENT ON COLUMN public.plg_workforce_absences.icd_code IS\n 'CID do atestado. Dado de sa\u00FAde: \u00E9 a policy de workforce_hr que o separa de quem edita cadastro \u2014 o V1 n\u00E3o separava (V1-069).';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_absences_periodo\n ON public.plg_workforce_absences (tenant_id, person_id, starts_on, ends_on);\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 5. PEND\u00CANCIAS (vista 16)\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nCREATE TABLE IF NOT EXISTS public.plg_workforce_pending_items (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n unit_id uuid,\n person_id uuid NOT NULL REFERENCES public.people(id) ON DELETE CASCADE,\n title text NOT NULL,\n category text CHECK (category IN ('documento', 'exame', 'epi', 'treinamento', 'assinatura', 'outro')),\n priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('baixa', 'normal', 'alta', 'urgente')),\n due_on date,\n resolved_at timestamptz,\n description text,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCOMMENT ON TABLE public.plg_workforce_pending_items IS\n 'O que falta na pasta de algu\u00E9m: documento, exame, EPI, assinatura (005). \u00C9 a lista que o painel cobra \u2014 \"Pend\u00EAncias cr\u00EDticas\" conta as n\u00E3o resolvidas de prioridade alta ou urgente.';\n\nCREATE INDEX IF NOT EXISTS plg_workforce_pending_abertas\n ON public.plg_workforce_pending_items (tenant_id, priority, due_on) WHERE resolved_at IS NULL;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 6. ESCOPO, CARIMBO E PERMISS\u00C3O\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT * FROM (VALUES\n ('plg_workforce_employment', 'workforce_hr.employment'),\n ('plg_workforce_vacations', 'workforce_hr.vacation'),\n ('plg_workforce_absences', 'workforce_hr.absence'),\n ('plg_workforce_pending_items', 'workforce_hr.pending_item')\n ) AS t(tbl, resource)\n LOOP\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', r.tbl);\n EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', r.tbl);\n EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tbl || '_updated_at', r.tbl);\n EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',\n r.tbl || '_updated_at', r.tbl);\n -- `workforce_hr` e n\u00E3o `workforce`: \u00E9 o nome do plugin que o scaffold usa\n -- para derivar `<plugin>.read/create/edit/delete`, ent\u00E3o passar outro nome\n -- aqui \u00C9 a separa\u00E7\u00E3o de permiss\u00E3o. Quem tem workforce.read v\u00EA escala e\n -- ponto e n\u00E3o v\u00EA sal\u00E1rio.\n PERFORM app.scaffold_table(format('public.%I', r.tbl)::regclass, r.resource, 'workforce_hr', false, 'enforce');\n END LOOP;\nEND $$;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 7. O PONTO PASSA A SABER DO AFASTAMENTO\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- O defeito que o FLOW da vista 10 registra: *\"N\u00E3o conversa com o ponto\n-- eletr\u00F4nico nem com a escala.\"* Duas telas sobre a mesma pessoa, no mesmo\n-- sistema, que n\u00E3o se olham \u2014 quem confere ponto v\u00EA FALTA em cima de um\n-- atestado que o RH lan\u00E7ou na semana passada, e precisa lembrar de cabe\u00E7a.\n--\n-- A view n\u00E3o muda de forma: ganha uma coluna no fim. `absent` continua sendo\n-- escala sem ponto, porque \u00E9 isso que ele \u00E9; o que muda \u00E9 que agora d\u00E1 para\n-- dizer POR QUE, quando algu\u00E9m sabe.\n\nCREATE OR REPLACE VIEW public.v_workforce_day\nWITH (security_invoker = 'true') AS\nWITH bruto AS (\n SELECT e.tenant_id, e.unit_id, e.person_id, e.work_date,\n min(e.effective_at) FILTER (WHERE e.kind = 'clock_in') AS first_in,\n max(e.effective_at) FILTER (WHERE e.kind = 'clock_out') AS last_out,\n count(*) FILTER (WHERE e.kind = 'break_start') AS break_count,\n count(*) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS aberturas,\n count(*) FILTER (WHERE e.kind IN ('clock_out', 'break_start')) AS fechamentos,\n max(e.effective_at) FILTER (WHERE e.kind IN ('clock_in', 'break_end')) AS ultima_abertura,\n sum(CASE\n WHEN e.kind IN ('clock_out', 'break_start') THEN extract(epoch FROM e.effective_at)\n WHEN e.kind IN ('clock_in', 'break_end') THEN -extract(epoch FROM e.effective_at)\n END) AS soma_com_sinal\n FROM public.v_workforce_time_entries e\n WHERE NOT e.voided\n GROUP BY e.tenant_id, e.unit_id, e.person_id, e.work_date\n),\nrealized AS (\n SELECT b.tenant_id, b.unit_id, b.person_id, b.work_date,\n b.first_in, b.last_out, b.break_count,\n (b.aberturas <> b.fechamentos) AS is_open,\n CASE WHEN b.aberturas <> b.fechamentos THEN b.ultima_abertura END AS open_since,\n -- Devolve \u00E0 soma a abertura sem par (003).\n (b.soma_com_sinal\n + CASE WHEN b.aberturas <> b.fechamentos\n THEN extract(epoch FROM b.ultima_abertura)\n ELSE 0 END) AS worked_seconds\n FROM bruto b\n),\nplanned AS (\n SELECT a.tenant_id, a.unit_id, a.person_id,\n s.schedule_date AS work_date,\n a.id AS assignment_id, a.role, a.status AS assignment_status,\n a.shift_label, a.shift_start_time, a.shift_end_time,\n (extract(epoch FROM a.shift_end_time - a.shift_start_time)\n + CASE WHEN a.shift_end_time <= a.shift_start_time THEN 86400 ELSE 0 END) AS planned_seconds\n FROM public.plg_workforce_schedule_assignments a\n JOIN public.plg_workforce_schedules s ON s.id = a.schedule_id\n WHERE s.status IN ('published', 'closed')\n AND a.role = 'titular'\n AND a.shift_start_time IS NOT NULL\n AND a.shift_end_time IS NOT NULL\n)\nSELECT coalesce(p.tenant_id, r.tenant_id) AS tenant_id,\n coalesce(p.unit_id, r.unit_id) AS unit_id,\n coalesce(p.person_id, r.person_id) AS person_id,\n coalesce(p.work_date, r.work_date) AS work_date,\n p.assignment_id,\n p.shift_label,\n p.shift_start_time,\n p.shift_end_time,\n p.assignment_status,\n (p.planned_seconds / 60)::integer AS planned_minutes,\n (r.worked_seconds / 60)::integer AS worked_minutes,\n ((r.worked_seconds - p.planned_seconds) / 60)::integer AS variance_minutes,\n r.first_in,\n r.last_out,\n r.break_count,\n coalesce(r.is_open, false) AS is_open,\n (r.person_id IS NULL) AS absent,\n (p.assignment_id IS NULL) AS outside_schedule,\n r.open_since,\n CASE\n WHEN r.person_id IS NULL THEN NULL\n WHEN r.is_open THEN 'open'\n WHEN (r.worked_seconds / 60) > public.workforce_max_shift_minutes()\n THEN 'span_too_long'\n END AS review_reason,\n -- Coluna nova, no fim. `security_invoker` na view + a policy de\n -- `workforce_hr` na tabela: quem n\u00E3o tem a permiss\u00E3o simplesmente n\u00E3o\n -- enxerga o afastamento, e a coluna volta nula. A folha de ponto n\u00E3o\n -- vira a porta dos fundos do CID.\n (SELECT a.kind\n FROM public.plg_workforce_absences a\n WHERE a.tenant_id = coalesce(p.tenant_id, r.tenant_id)\n AND a.person_id = coalesce(p.person_id, r.person_id)\n AND coalesce(p.work_date, r.work_date) BETWEEN a.starts_on\n AND coalesce(a.ends_on, coalesce(p.work_date, r.work_date))\n ORDER BY a.starts_on DESC\n LIMIT 1) AS absence_kind\n FROM planned p\n FULL OUTER JOIN realized r\n ON r.tenant_id = p.tenant_id\n AND r.person_id = p.person_id\n AND r.work_date = p.work_date;\n\nCOMMENT ON COLUMN public.v_workforce_day.absence_kind IS\n 'O afastamento que cobre este dia, quando o RH lan\u00E7ou um (005). \u00C9 o conserto do defeito que o V1 tem: l\u00E1 a falta lan\u00E7ada no RH n\u00E3o aparece no ponto, e quem confere v\u00EA FALTA em cima de um atestado.';\n\nGRANT SELECT ON public.v_workforce_day TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- 8. A VIS\u00C3O GERAL \u2014 os oito n\u00FAmeros\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n--\n-- Os mesmos oito cart\u00F5es do V1, com tr\u00EAs diferen\u00E7as, e as tr\u00EAs s\u00E3o conserto de\n-- defeito registrado no FLOW:\n--\n-- V1-072 \"Experi\u00EAncia vencendo\" s\u00F3 olhava PROFISSIONAIS, porque o V1 tem\n-- duas tabelas. O V2 tem uma pessoa, ent\u00E3o olha todo mundo.\n-- V1-073 \"F\u00E9rias a vencer\" mostrava a data e N\u00C3O o nome. Aqui a view devolve\n-- person_id e nome, e a tela linka para a ficha.\n-- V1-074 aniversariante do dia 1\u00BA ca\u00EDa no m\u00EAs anterior, por compara\u00E7\u00E3o de\n-- data em UTC. `extract(month \u2026)` n\u00E3o tem esse problema.\n\nCREATE OR REPLACE VIEW public.v_workforce_hr_overview\nWITH (security_invoker = 'true') AS\nWITH base AS (\n SELECT e.tenant_id, e.person_id, e.admission_date, e.dismissal_date, e.probation_final_end,\n p.name, p.date_of_birth, p.is_active\n FROM public.plg_workforce_employment e\n JOIN public.people p ON p.id = e.person_id\n)\nSELECT b.tenant_id,\n count(*) FILTER (WHERE b.dismissal_date IS NULL AND coalesce(b.is_active, true)) AS headcount,\n count(*) FILTER (WHERE b.admission_date >= current_date - 30) AS admissions_30d,\n count(*) FILTER (WHERE b.dismissal_date >= current_date - 30) AS dismissals_30d,\n count(*) FILTER (WHERE b.probation_final_end BETWEEN current_date AND current_date + 60) AS probation_ending,\n count(*) FILTER (WHERE b.dismissal_date IS NULL\n AND extract(month FROM b.date_of_birth) = extract(month FROM current_date)) AS birthdays_this_month,\n (SELECT count(*) FROM public.plg_workforce_vacations v\n WHERE v.tenant_id = b.tenant_id AND v.status <> 'concluida'\n AND v.accrual_end BETWEEN current_date AND current_date + 60) AS vacations_due_60d,\n (SELECT count(*) FROM public.plg_workforce_pending_items i\n WHERE i.tenant_id = b.tenant_id AND i.resolved_at IS NULL\n AND i.priority IN ('alta', 'urgente')) AS critical_pending\n FROM base b\n GROUP BY b.tenant_id;\n\nCOMMENT ON VIEW public.v_workforce_hr_overview IS\n 'Os oito n\u00FAmeros do painel de RH do V1 (005). Turnover n\u00E3o est\u00E1 aqui de prop\u00F3sito: \u00E9 demiss\u00F5es \u00F7 headcount, e uma divis\u00E3o que a tela faz \u00E9 uma divis\u00E3o que a tela pode formatar \u2014 a view devolve as duas parcelas.';\n\n-- Quem vence, COM NOME \u2014 o V1-073 \u00E9 justamente a lista sem nome.\nCREATE OR REPLACE VIEW public.v_workforce_hr_upcoming\nWITH (security_invoker = 'true') AS\nSELECT v.tenant_id, 'vacation'::text AS kind, v.person_id, p.name AS person_name,\n v.accrual_end AS due_on, v.status AS detail\n FROM public.plg_workforce_vacations v\n JOIN public.people p ON p.id = v.person_id\n WHERE v.status <> 'concluida' AND v.accrual_end BETWEEN current_date AND current_date + 60\nUNION ALL\nSELECT e.tenant_id, 'probation', e.person_id, p.name,\n e.probation_final_end, e.job_title\n FROM public.plg_workforce_employment e\n JOIN public.people p ON p.id = e.person_id\n WHERE e.probation_final_end BETWEEN current_date AND current_date + 60\nUNION ALL\nSELECT i.tenant_id, 'pending', i.person_id, p.name, i.due_on, i.title\n FROM public.plg_workforce_pending_items i\n JOIN public.people p ON p.id = i.person_id\n WHERE i.resolved_at IS NULL AND i.priority IN ('alta', 'urgente');\n\nCOMMENT ON VIEW public.v_workforce_hr_upcoming IS\n 'O que vence nos pr\u00F3ximos 60 dias, com o NOME de quem (005). O V1 lista f\u00E9rias a vencer s\u00F3 com a data \u2014 V1-073 \u2014, o que obriga a procurar a pessoa em outra tela.';\n\nGRANT SELECT ON public.v_workforce_hr_overview TO authenticated, service_role;\nGRANT SELECT ON public.v_workforce_hr_upcoming TO authenticated, service_role;\n";
|
|
7
|
+
export declare const MIGRATION_006_O_CUSTO_DE_PESSOAL_E_UM_RAMO_DO_PLANO = "-- O custo de pessoal \u00E9 um RAMO DO PLANO, e quem o aponta \u00E9 a casa.\n--\n-- \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\n--\n-- A tela \"Encargos e honor\u00E1rios\" (RH \u2192 Custos) l\u00EA o raz\u00E3o do Financeiro e soma\n-- toda linha de conta a pagar que tenha conta cont\u00E1bil. Toda. Medido no cluster\n-- de Pertinho do C\u00E9u em 19/09/2026, o que ela chamava de custo de pessoal\n-- inclu\u00EDa:\n--\n-- 6.2.1 Aluguel R$ 21.061,99\n-- 6.2.3 IPTU R$ 26.613,73\n-- 6.5.5 ICMS R$ 30.964,92\n-- 6.4.1 Equipamentos R$ 82.595,87\n-- 6.3.1 Energia/Light R$ 27.185,62\n--\n-- \u2014 ao lado das que s\u00E3o, de fato, pessoal:\n--\n-- 5 DESPESAS COM PESSOAL (26 contas: Sal\u00E1rio, FGTS, INSS, Comiss\u00E3o,\n-- Hora Extra, Adiantamento, IRRF Folha, Vale Alimenta\u00E7\u00E3o, FREELANCER\n-- com oito filhas\u2026)\n--\n-- Um total que soma aluguel e ICMS sob o t\u00EDtulo \"custo de pessoal\" n\u00E3o est\u00E1\n-- impreciso: est\u00E1 errado, e erra para cima, que \u00E9 o lado que faz algu\u00E9m decidir\n-- contratar menos.\n--\n-- \u2500\u2500 Por que uma configura\u00E7\u00E3o, e n\u00E3o 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\n--\n-- N\u00E3o existe como o c\u00F3digo saber qual ramo \u00E9 pessoal. O plano de contas \u00E9 uma\n-- DECLARA\u00C7\u00C3O da casa (ver `040_o_demonstrativo_mostra_o_plano_inteiro`), e cada\n-- casa numera o dela: aqui pessoal \u00E9 o ramo `5`; o template que o SDK semeia\n-- para um tenant novo p\u00F5e folha em `4.03`. Deduzir pelo NOME da conta seria\n-- adivinhar em portugu\u00EAs \u2014 e `8.2.4 Rescis\u00E3o` e `8.2.6 Pro-labore`, que tamb\u00E9m\n-- s\u00E3o gente, moram fora do ramo 5 neste mesmo plano, por escolha de quem montou.\n--\n-- Ent\u00E3o a chave guarda a escolha, e a aus\u00EAncia dela \u00E9 uma aus\u00EAncia honesta: sem\n-- nada escolhido a tela mostra TODAS as despesas classificadas e diz que \u00E9 o que\n-- est\u00E1 fazendo. Um padr\u00E3o inventado ('5') faria a tela mentir com convic\u00E7\u00E3o no\n-- primeiro tenant cujo plano n\u00E3o usa esse n\u00FAmero.\n--\n-- \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\n--\n-- Um array de C\u00D3DIGOS, n\u00E3o de ids. O c\u00F3digo \u00E9 o que carrega a hierarquia: quem\n-- escolhe '5' escolhe o ramo inteiro \u2014 '5.4' e '5.4.1' entram com ele, e uma\n-- conta nova criada amanh\u00E3 dentro do ramo entra sozinha. Com id seria preciso\n-- re-marcar a configura\u00E7\u00E3o a cada conta nova, e ningu\u00E9m lembra.\n--\n-- `tenant_setting_set` cobra `settings.manage` e recusa chave n\u00E3o registrada \u2014\n-- \u00E9 por isso que este arquivo existe. Idempotente: `seed_setting_keys` faz\n-- upsert por chave.\n\nSELECT app.seed_setting_keys($$[\n {\n \"key\": \"workforce.payroll_account_codes\", \"type\": \"array\", \"default\": null,\n \"reader\": \"plugin:workforce\",\n \"description\": \"Os ramos do plano de contas que s\u00E3o custo de pessoal, por c\u00F3digo ('5', '8.2.4'). Um c\u00F3digo traz toda a descend\u00EAncia dele. Vazio ou nulo = a tela mostra todas as despesas classificadas e avisa que n\u00E3o h\u00E1 ramo escolhido.\"\n }\n]$$::jsonb);\n";
|
|
8
|
+
export declare const MIGRATIONS: Array<{
|
|
9
|
+
id: string;
|
|
10
|
+
sql: string;
|
|
11
|
+
}>;
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,s2xHA26DlC,CAAA;AAED,eAAO,MAAM,yCAAyC,ggVAiKrD,CAAA;AAED,eAAO,MAAM,mDAAmD,6mLAkG/D,CAAA;AAED,eAAO,MAAM,wDAAwD,klSA8JpE,CAAA;AAED,eAAO,MAAM,4CAA4C,o/SAyKxD,CAAA;AAED,eAAO,MAAM,gDAAgD,yshCAuc5D,CAAA;AAED,eAAO,MAAM,mDAAmD,2iIAyD/D,CAAA;AAED,eAAO,MAAM,UAAU,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAQzD,CAAA"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PayrollAccountsHandle {
|
|
2
|
+
/** Os códigos escolhidos, ou null quando a casa nunca escolheu. */
|
|
3
|
+
codes: string[] | null;
|
|
4
|
+
/** Grava (ou apaga, com null). */
|
|
5
|
+
save: (next: string[] | null) => Promise<void>;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
saving: boolean;
|
|
8
|
+
/** A gravação foi recusada — em geral falta `settings.manage`. */
|
|
9
|
+
error: string | null;
|
|
10
|
+
/** Sem backend montado (mock/standalone): a tela não oferece o controle. */
|
|
11
|
+
supported: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function usePayrollAccounts(): PayrollAccountsHandle;
|
|
14
|
+
//# sourceMappingURL=usePayrollAccounts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usePayrollAccounts.d.ts","sourceRoot":"","sources":["../../src/settings/usePayrollAccounts.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,qBAAqB;IACpC,mEAAmE;IACnE,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;IACtB,kCAAkC;IAClC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9C,OAAO,EAAE,OAAO,CAAA;IAChB,MAAM,EAAE,OAAO,CAAA;IACf,kEAAkE;IAClE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,4EAA4E;IAC5E,SAAS,EAAE,OAAO,CAAA;CACnB;AAcD,wBAAgB,kBAAkB,IAAI,qBAAqB,CA2C1D"}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type StoreApi } from 'zustand/vanilla';
|
|
2
|
+
import type { WorkforceDataProvider } from './data/types';
|
|
3
|
+
import type { DaySchedule, ScheduleTemplate, TimeEntry, WorkDay, WorkforcePeriod, WorkforcePerson } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* O estado que mais de uma tela do módulo lê.
|
|
6
|
+
*
|
|
7
|
+
* O que é só de uma tela (o formulário de turno, o diálogo de ajuste) fica na
|
|
8
|
+
* própria tela — um store que guarda rascunho de formulário é um store que
|
|
9
|
+
* ninguém consegue limpar.
|
|
10
|
+
*/
|
|
11
|
+
export interface WorkforceUIState {
|
|
12
|
+
/** O dia selecionado no calendário de Escalas, em 'YYYY-MM-DD'. */
|
|
13
|
+
selectedDate: string;
|
|
14
|
+
setSelectedDate: (date: string) => void;
|
|
15
|
+
schedules: DaySchedule[];
|
|
16
|
+
schedulesLoading: boolean;
|
|
17
|
+
fetchSchedules: (from: string, to: string) => Promise<void>;
|
|
18
|
+
templates: ScheduleTemplate[];
|
|
19
|
+
templatesLoading: boolean;
|
|
20
|
+
fetchTemplates: () => Promise<void>;
|
|
21
|
+
entries: TimeEntry[];
|
|
22
|
+
days: WorkDay[];
|
|
23
|
+
timeLoading: boolean;
|
|
24
|
+
fetchTime: (from: string, to: string) => Promise<void>;
|
|
25
|
+
periods: WorkforcePeriod[];
|
|
26
|
+
periodsLoading: boolean;
|
|
27
|
+
fetchPeriods: () => Promise<void>;
|
|
28
|
+
staff: WorkforcePerson[];
|
|
29
|
+
fetchStaff: () => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createWorkforceStore(provider: WorkforceDataProvider): StoreApi<WorkforceUIState>;
|
|
32
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC5D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,SAAS,EAAE,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAElH;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,mEAAmE;IACnE,YAAY,EAAE,MAAM,CAAA;IACpB,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAEvC,SAAS,EAAE,WAAW,EAAE,CAAA;IACxB,gBAAgB,EAAE,OAAO,CAAA;IACzB,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAE3D,SAAS,EAAE,gBAAgB,EAAE,CAAA;IAC7B,gBAAgB,EAAE,OAAO,CAAA;IACzB,cAAc,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAEnC,OAAO,EAAE,SAAS,EAAE,CAAA;IACpB,IAAI,EAAE,OAAO,EAAE,CAAA;IACf,WAAW,EAAE,OAAO,CAAA;IACpB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAEtD,OAAO,EAAE,eAAe,EAAE,CAAA;IAC1B,cAAc,EAAE,OAAO,CAAA;IACvB,YAAY,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAEjC,KAAK,EAAE,eAAe,EAAE,CAAA;IACxB,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC;AAOD,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,qBAAqB,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAyEhG"}
|