@fayz-ai/plugin-agenda 0.13.3 → 0.13.5

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/dist/index.js CHANGED
@@ -3243,17 +3243,6 @@ function CalendarView() {
3243
3243
  }, []);
3244
3244
  return /* @__PURE__ */ jsxs("div", { ref: shellRef, className: "flex", style: { height: shellHeight ?? void 0 }, children: [
3245
3245
  /* @__PURE__ */ jsxs("div", { className: "hidden md:flex w-[220px] shrink-0 flex-col overflow-y-auto py-4 px-3 mr-4", children: [
3246
- /* @__PURE__ */ jsxs(
3247
- "button",
3248
- {
3249
- onClick: () => openModal("create"),
3250
- className: "flex items-center gap-2 rounded-button bg-primary text-primary-foreground border border-primary pl-3 pr-4 py-1.5 text-sm font-semibold hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-all mb-6 w-fit",
3251
- children: [
3252
- /* @__PURE__ */ jsx(Plus, { className: "h-4 w-4" }),
3253
- t2("agenda.sidebar.create")
3254
- ]
3255
- }
3256
- ),
3257
3246
  /* @__PURE__ */ jsx(MiniCalendar, { selectedDate, onDateSelect: handleMiniDateSelect }),
3258
3247
  config.capabilities.professional && /* @__PURE__ */ jsxs(Fragment, { children: [
3259
3248
  /* @__PURE__ */ jsx("div", { className: "h-px bg-border my-4" }),
@@ -3282,7 +3271,7 @@ function CalendarView() {
3282
3271
  ) })
3283
3272
  ] })
3284
3273
  ] }),
3285
- isMobile && /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
3274
+ /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
3286
3275
  PageActions,
3287
3276
  {
3288
3277
  actions: [
@@ -3329,29 +3318,15 @@ function CalendarView() {
3329
3318
  ] }),
3330
3319
  !(isMobile && !mobileGridActive) && /* @__PURE__ */ jsx("h2", { className: "truncate text-base font-normal sm:text-lg", children: calendarTitle })
3331
3320
  ] }),
3332
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 shrink-0", children: [
3333
- /* @__PURE__ */ jsx("div", { className: "hidden md:flex items-center gap-1", children: viewOptions.map((opt) => /* @__PURE__ */ jsx(
3334
- "button",
3335
- {
3336
- onClick: () => handleViewChange(opt.key),
3337
- className: `rounded-md px-3 py-1 text-sm transition-colors ${currentView === opt.key ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted/50"}`,
3338
- children: t2(opt.labelKey)
3339
- },
3340
- opt.key
3341
- )) }),
3342
- /* @__PURE__ */ jsx(
3343
- Button,
3344
- {
3345
- variant: "outline",
3346
- size: "icon",
3347
- onClick: () => window.location.hash = "/settings/agenda",
3348
- "aria-label": t2("agenda.settings.title"),
3349
- title: t2("agenda.settings.title"),
3350
- className: "ml-1 hidden md:inline-flex",
3351
- children: /* @__PURE__ */ jsx(Settings, { className: "h-4 w-4" })
3352
- }
3353
- )
3354
- ] })
3321
+ /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1 shrink-0", children: /* @__PURE__ */ jsx("div", { className: "hidden md:flex items-center gap-1", children: viewOptions.map((opt) => /* @__PURE__ */ jsx(
3322
+ "button",
3323
+ {
3324
+ onClick: () => handleViewChange(opt.key),
3325
+ className: `rounded-md px-3 py-1 text-sm transition-colors ${currentView === opt.key ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground hover:bg-muted/50"}`,
3326
+ children: t2(opt.labelKey)
3327
+ },
3328
+ opt.key
3329
+ )) }) })
3355
3330
  ] }),
3356
3331
  /* @__PURE__ */ jsx("div", { className: "flex md:hidden items-center gap-1 overflow-x-auto px-3 pb-2", children: mobileViewOptions.map((opt) => /* @__PURE__ */ jsx(
3357
3332
  "button",
@@ -4914,7 +4889,6 @@ var en = {
4914
4889
  "agenda.sidebar.searchAgendas": "Find an agenda",
4915
4890
  "agenda.sidebar.tooManyAgendas": "Up to {max} agendas at a time \u2014 uncheck one to add another.",
4916
4891
  "agenda.sidebar.allLocations": "All locations",
4917
- "agenda.sidebar.create": "Create",
4918
4892
  "agenda.sidebar.location": "Location",
4919
4893
  "agenda.sidebar.status": "Status",
4920
4894
  "agenda.toolbar.next": "Next",
@@ -5138,7 +5112,6 @@ var ptBR = {
5138
5112
  "agenda.sidebar.searchAgendas": "Buscar agenda",
5139
5113
  "agenda.sidebar.tooManyAgendas": "At\xE9 {max} agendas por vez \u2014 desmarque uma para incluir outra.",
5140
5114
  "agenda.sidebar.allLocations": "Todos os locais",
5141
- "agenda.sidebar.create": "Criar",
5142
5115
  "agenda.sidebar.location": "Local",
5143
5116
  "agenda.sidebar.status": "Status",
5144
5117
  "agenda.toolbar.next": "Pr\xF3ximo",
@@ -6927,6 +6900,678 @@ CREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker = true) AS
6927
6900
  LIMIT 1
6928
6901
  ) inv ON true;
6929
6902
  `;
6903
+ var MIGRATION_007_O_CASCADE_LEVOU_A_AGENDA_JUNTO = `-- O CASCADE levou a agenda junto.
6904
+ --
6905
+ -- Sintoma: a tela de check-in (\`#/agenda/checkin\`) demora e n\xE3o abre. A causa
6906
+ -- n\xE3o est\xE1 na agenda \u2014 est\xE1 numa migration do FINANCEIRO, escrita tr\xEAs dias
6907
+ -- antes.
6908
+ --
6909
+ -- As migrations 044 e 045 do plugin-financial come\xE7am com:
6910
+ --
6911
+ -- DROP VIEW IF EXISTS public.v_financial_invoice_balances CASCADE;
6912
+ --
6913
+ -- e recriam, logo abaixo, \`v_financial_invoice_balances\` e \`v_invoice_balances\`
6914
+ -- \u2014 as duas que o autor (eu) sabia que dependiam. O CASCADE, por\xE9m, n\xE3o se
6915
+ -- limita ao que o autor conhece: ele derruba a \xE1rvore inteira. Debaixo de
6916
+ -- \`v_invoice_balances\` moravam mais seis views, de dois plugins:
6917
+ --
6918
+ -- v_appointments (agenda \u2014 o read model do agendamento)
6919
+ -- \u251C\u2500\u2500 rep_appointments_by_period
6920
+ -- \u2502 \u2514\u2500\u2500 plg_agenda_rep_bookings
6921
+ -- \u251C\u2500\u2500 rep_confirmation_queue
6922
+ -- \u251C\u2500\u2500 rep_revenue_by_professional
6923
+ -- \u2514\u2500\u2500 rep_revenue_by_service
6924
+ -- plg_financial_rep_receivables (financeiro \u2014 restaurada na 053 de l\xE1)
6925
+ --
6926
+ -- \`v_appointments\` l\xEA \`v_invoice_balances\` desde a migration 006 desta corrente
6927
+ -- (o LATERAL que fez a agenda parar de apurar 8.134 faturas por agendamento).
6928
+ -- Essa depend\xEAncia foi o conserto de um problema real e continua certa; o que
6929
+ -- ela criou, sem ningu\xE9m notar, foi uma aresta do financeiro para a agenda \u2014
6930
+ -- e \xE9 por ela que o CASCADE passou.
6931
+ --
6932
+ -- Nada gritou. O \`DROP\` n\xE3o avisa o que levou, o \`apply\` das duas migrations
6933
+ -- passou verde, a lista de contas a pagar (o que eu estava consertando) ficou
6934
+ -- correta, e a agenda s\xF3 falha quando algu\xE9m a abre. O erro que chega ao
6935
+ -- navegador \xE9 um 404 do PostgREST sobre uma rela\xE7\xE3o que o c\xF3digo tem certeza
6936
+ -- de que existe \u2014 pista que aponta para o lugar errado.
6937
+ --
6938
+ -- \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\u2500
6939
+ --
6940
+ -- Recria as cinco views da agenda, com o corpo EXATO em que estavam: o de
6941
+ -- \`v_appointments\` vem da migration 006 (a forma com LATERAL, n\xE3o a do
6942
+ -- baseline), e os quatro relat\xF3rios v\xEAm do baseline, que n\xE3o os alterou desde
6943
+ -- ent\xE3o. Nenhuma coluna muda, nenhuma regra muda \u2014 o que muda \xE9 existirem.
6944
+ --
6945
+ -- \`WITH (security_invoker = true)\` expl\xEDcito em todas: uma view recriada n\xE3o
6946
+ -- herda op\xE7\xE3o nenhuma, e sem isso ela passa a rodar com os direitos do dono \u2014
6947
+ -- a RLS de quem l\xEA deixa de valer. Os GRANTs tamb\xE9m s\xE3o reescritos, porque
6948
+ -- morreram com as views.
6949
+ --
6950
+ -- \u2500\u2500 A li\xE7\xE3o, para a pr\xF3xima \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6951
+ --
6952
+ -- Antes de um \`DROP \u2026 CASCADE\`, pergunte ao banco quem depende:
6953
+ --
6954
+ -- SELECT DISTINCT dependente.relname
6955
+ -- FROM pg_depend d
6956
+ -- JOIN pg_rewrite r ON r.oid = d.objid
6957
+ -- JOIN pg_class dependente ON dependente.oid = r.ev_class
6958
+ -- WHERE d.refobjid = 'public.v_financial_invoice_balances'::regclass
6959
+ -- AND dependente.relname <> 'v_financial_invoice_balances';
6960
+ --
6961
+ -- A lista que voltar \xE9 a lista que a migration tem que recriar. E depois de
6962
+ -- aplicar, o diff entre as views que o c\xF3digo declara e as que o banco tem \xE9 o
6963
+ -- que pega o que escapou \u2014 foi ele que encontrou estas seis.
6964
+ --
6965
+ -- Idempotente.
6966
+
6967
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6968
+ -- 1. v_appointments \u2014 corpo da migration 006 (LATERAL sobre v_invoice_balances)
6969
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6970
+
6971
+ CREATE OR REPLACE VIEW public.v_appointments WITH (security_invoker = true) AS
6972
+ SELECT b.id,
6973
+ b.tenant_id,
6974
+ b.kind,
6975
+ b.starts_at,
6976
+ b.ends_at,
6977
+ b.status,
6978
+ b.notes,
6979
+ b.order_id,
6980
+ b.location_id,
6981
+ b.metadata,
6982
+ b.created_at,
6983
+ b.updated_at,
6984
+ b.party_id AS client_id,
6985
+ COALESCE(pc.name, b.metadata ->> 'title'::text) AS client_name,
6986
+ pc.phone AS client_phone,
6987
+ pc.email AS client_email,
6988
+ pc.avatar_url AS client_avatar_url,
6989
+ b.assignee_id AS professional_id,
6990
+ ps.name AS professional_name,
6991
+ ps.avatar_url AS professional_avatar_url,
6992
+ l.name AS location_name,
6993
+ o.total AS order_total,
6994
+ o.status AS order_status,
6995
+ EXTRACT(epoch FROM COALESCE(b.ends_at, b.starts_at) - b.starts_at)::integer / 60 AS total_duration_minutes,
6996
+ o.reference_number,
6997
+ o.status AS stage,
6998
+ o.metadata ->> 'direction'::text AS direction,
6999
+ ( SELECT json_agg(json_build_object('id', oi.id, 'serviceId', oi.product_id, 'name', oi.name, 'durationMinutes', oi.duration_minutes, 'price', oi.unit_price, 'assigneeId', oi.assignee_id) ORDER BY oi.sort_order) AS json_agg
7000
+ FROM items oi
7001
+ WHERE oi.order_id = b.order_id) AS services,
7002
+ inv.status AS payment_status,
7003
+ inv.paid AS order_paid,
7004
+ inv.balance AS order_balance
7005
+ FROM appointments b
7006
+ LEFT JOIN people pc ON pc.id = b.party_id
7007
+ LEFT JOIN people ps ON ps.id = b.assignee_id
7008
+ LEFT JOIN app.units l ON l.id = b.location_id
7009
+ LEFT JOIN orders o ON o.id = b.order_id
7010
+ LEFT JOIN LATERAL (
7011
+ SELECT vb.status, vb.paid, vb.balance
7012
+ FROM v_invoice_balances vb
7013
+ WHERE vb.invoice_id = b.order_id
7014
+ LIMIT 1
7015
+ ) inv ON true;
7016
+
7017
+ COMMENT ON VIEW public.v_appointments IS
7018
+ 'O read model do agendamento: a linha do calend\xE1rio com cliente, '
7019
+ 'profissional, unidade, itens e o estado de pagamento da Venda. L\xEA '
7020
+ 'v_invoice_balances por LATERAL para que o filtro des\xE7a por baixo do '
7021
+ 'agregado \u2014 como JOIN, a agenda apurava as faturas do tenant inteiro por '
7022
+ 'agendamento.';
7023
+
7024
+ GRANT SELECT ON TABLE public.v_appointments TO authenticated, service_role;
7025
+
7026
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7027
+ -- 2. Os quatro relat\xF3rios que penduram nela, e o quinto que pendura no primeiro
7028
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7029
+
7030
+ CREATE OR REPLACE VIEW public.rep_appointments_by_period WITH (security_invoker = true) AS
7031
+ SELECT tenant_id,
7032
+ id AS booking_id,
7033
+ (starts_at)::date AS date,
7034
+ starts_at,
7035
+ client_id,
7036
+ client_name,
7037
+ professional_id,
7038
+ professional_name,
7039
+ COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
7040
+ status,
7041
+ order_total AS revenue,
7042
+ created_at,
7043
+ updated_at
7044
+ FROM public.v_appointments b;
7045
+
7046
+ GRANT SELECT ON TABLE public.rep_appointments_by_period TO authenticated, service_role;
7047
+
7048
+ CREATE OR REPLACE VIEW public.plg_agenda_rep_bookings WITH (security_invoker = true) AS
7049
+ SELECT tenant_id,
7050
+ booking_id,
7051
+ date,
7052
+ starts_at,
7053
+ client_id,
7054
+ client_name,
7055
+ professional_id,
7056
+ professional_name,
7057
+ service_name,
7058
+ status,
7059
+ revenue AS booked_revenue,
7060
+ CASE
7061
+ WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN (0)::numeric
7062
+ ELSE COALESCE(revenue, (0)::numeric)
7063
+ END AS effective_revenue,
7064
+ CASE
7065
+ WHEN (status = ANY (ARRAY['cancelled'::text, 'no_show'::text])) THEN COALESCE(revenue, (0)::numeric)
7066
+ ELSE (0)::numeric
7067
+ END AS lost_revenue,
7068
+ CASE
7069
+ WHEN (status = 'completed'::text) THEN 1
7070
+ ELSE 0
7071
+ END AS is_completed,
7072
+ CASE
7073
+ WHEN (status = 'cancelled'::text) THEN 1
7074
+ ELSE 0
7075
+ END AS is_cancelled,
7076
+ CASE
7077
+ WHEN (status = 'no_show'::text) THEN 1
7078
+ ELSE 0
7079
+ END AS is_no_show,
7080
+ created_at,
7081
+ updated_at
7082
+ FROM public.rep_appointments_by_period b;
7083
+
7084
+ GRANT SELECT ON TABLE public.plg_agenda_rep_bookings TO authenticated, service_role;
7085
+
7086
+ CREATE OR REPLACE VIEW public.rep_confirmation_queue WITH (security_invoker = true) AS
7087
+ SELECT tenant_id,
7088
+ id AS booking_id,
7089
+ (starts_at)::date AS date,
7090
+ starts_at,
7091
+ client_id,
7092
+ client_name,
7093
+ professional_id,
7094
+ professional_name,
7095
+ COALESCE(((services -> 0) ->> 'name'::text), ((services -> 0) ->> 'serviceName'::text)) AS service_name,
7096
+ 'pending'::text AS confirmation_status,
7097
+ NULL::text AS confirmation_channel,
7098
+ NULL::timestamp with time zone AS confirmation_sent_at,
7099
+ NULL::timestamp with time zone AS confirmed_at,
7100
+ created_at,
7101
+ updated_at
7102
+ FROM public.v_appointments b
7103
+ WHERE ((status <> ALL (ARRAY['cancelled'::text, 'no_show'::text])) AND (starts_at >= (now() - '1 day'::interval)));
7104
+
7105
+ GRANT SELECT ON TABLE public.rep_confirmation_queue TO authenticated, service_role;
7106
+
7107
+ CREATE OR REPLACE VIEW public.rep_revenue_by_professional WITH (security_invoker = true) AS
7108
+ SELECT tenant_id,
7109
+ (starts_at)::date AS date,
7110
+ professional_id,
7111
+ COALESCE(professional_name, 'Sem profissional'::text) AS professional_name,
7112
+ (count(*))::integer AS appointment_count,
7113
+ (COALESCE(sum(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS total_revenue,
7114
+ (COALESCE(avg(NULLIF(order_total, (0)::numeric)), (0)::numeric))::numeric(14,2) AS avg_ticket,
7115
+ (0)::numeric(14,2) AS commission,
7116
+ max(updated_at) AS updated_at
7117
+ FROM public.v_appointments b
7118
+ WHERE (status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
7119
+ GROUP BY tenant_id, ((starts_at)::date), professional_id, COALESCE(professional_name, 'Sem profissional'::text);
7120
+
7121
+ GRANT SELECT ON TABLE public.rep_revenue_by_professional TO authenticated, service_role;
7122
+
7123
+ CREATE OR REPLACE VIEW public.rep_revenue_by_service WITH (security_invoker = true) AS
7124
+ SELECT b.tenant_id,
7125
+ (b.starts_at)::date AS date,
7126
+ NULLIF((service_item.value ->> 'serviceId'::text), ''::text) AS service_id,
7127
+ COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text) AS service_name,
7128
+ (count(DISTINCT b.id))::integer AS quantity,
7129
+ (COALESCE(sum((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS total_revenue,
7130
+ (COALESCE(avg((NULLIF((service_item.value ->> 'price'::text), ''::text))::numeric), (0)::numeric))::numeric(14,2) AS avg_ticket,
7131
+ max(b.updated_at) AS updated_at
7132
+ FROM (public.v_appointments b
7133
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE((b.services)::jsonb, '[]'::jsonb)) service_item(value))
7134
+ WHERE (b.status <> ALL (ARRAY['cancelled'::text, 'no_show'::text]))
7135
+ GROUP BY b.tenant_id, ((b.starts_at)::date), NULLIF((service_item.value ->> 'serviceId'::text), ''::text), COALESCE((service_item.value ->> 'name'::text), (service_item.value ->> 'serviceName'::text), 'Sem servico'::text);
7136
+
7137
+ GRANT SELECT ON TABLE public.rep_revenue_by_service TO authenticated, service_role;
7138
+ `;
7139
+ var MIGRATION_008_A_AGENDA_DO_V1_ATRAVESSA = `-- A agenda do V1 atravessa.
7140
+ --
7141
+ -- 354.206 agendamentos e 314.310 servi\xE7os agendados do Espa\xE7o Facial n\xE3o tinham
7142
+ -- para onde ir: \`appointments\` e \`appointment_services\` n\xE3o estavam nem na
7143
+ -- \`migration.allowlist\` nem na \`migration.excluded_tables\`. N\xE3o era uma decis\xE3o
7144
+ -- de deixar de fora \u2014 era a aus\xEAncia de decis\xE3o, e a tela de check-in abria
7145
+ -- vazia por isso.
7146
+ --
7147
+ -- \u2500\u2500 O de-para \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7148
+ --
7149
+ -- V1 appointments \u2192 public.appointments
7150
+ -- V1 appointment_services \u2192 public.appointment_items
7151
+ --
7152
+ -- Tr\xEAs refer\xEAncias o ledger resolve, porque as tr\xEAs j\xE1 atravessaram:
7153
+ -- \`client_id\` \u2192 \`public.people\` (123.304 linhas), \`professional_id\` \u2192
7154
+ -- \`public.people\` (300) e \`service_id\` \u2192 \`public.products\` (122).
7155
+ --
7156
+ -- \u2500\u2500 A unidade, que o V1 n\xE3o guarda no agendamento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7157
+ --
7158
+ -- \`public.appointments\` do V2 quer \`unit_id\` \u2014 \xE9 por ela que a rede de 63
7159
+ -- unidades filtra a agenda. O V1 n\xE3o tem essa coluna: ele guarda
7160
+ -- \`service_location_id\`, a SALA, e \xE9 a sala que sabe a unidade
7161
+ -- (\`service_locations.unit_id\`, um uuid que casa com \`companies.uuid\`).
7162
+ --
7163
+ -- A ponte \xE9 feita no extrator (\`augment\` do Step), n\xE3o aqui, porque ela \xE9
7164
+ -- conhecimento da ORIGEM. Medido em 19/09/2026, tenant 1:
7165
+ --
7166
+ -- agendamentos 354.206
7167
+ -- com sala que resolve a unidade 352.315 (99,5%)
7168
+ -- sem sala nenhuma 1.891
7169
+ --
7170
+ -- Os 1.891 sem sala entram com \`unit_id\` NULL \u2014 \xE9 o que o V1 tem, e inventar
7171
+ -- unidade para eles seria coloc\xE1-los na agenda de algu\xE9m.
7172
+ --
7173
+ -- \u2500\u2500 O estado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7174
+ --
7175
+ -- O vocabul\xE1rio do V1 tem sete palavras; o de \`appointment_kinds.appointment\`
7176
+ -- tem seis. Cinco s\xE3o a mesma palavra. Sobram duas, e o mapa \xE9 declarado no
7177
+ -- \`writer_args\` \u2014 dado, n\xE3o c\xF3digo:
7178
+ --
7179
+ -- unmarked \u2192 cancelled desmarcado \xE9 cancelado; o V2 n\xE3o tem a terceira
7180
+ -- palavra, e 'unmarked' n\xE3o \xE9 um estado, \xE9 a
7181
+ -- aus\xEAncia de um.
7182
+ -- waiting \u2192 confirmed a cliente chegou e espera. O V2 diz isso com
7183
+ -- \`checked_in_at\` (migration 003 desta corrente),
7184
+ -- que o pr\xF3prio V1 j\xE1 preenche \u2014 ent\xE3o a palavra
7185
+ -- vira 'confirmed' e o REL\xD3GIO carrega o resto.
7186
+ --
7187
+ -- O original fica em \`metadata.migration.source_status\`, como em toda tradu\xE7\xE3o
7188
+ -- de vocabul\xE1rio deste motor.
7189
+ --
7190
+ -- \u2500\u2500 O que N\xC3O vem \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7191
+ --
7192
+ -- O agendamento do V1 n\xE3o tem Venda. \`order_id\` fica NULL, e \xE9 por isso que
7193
+ -- \`v_appointments\` traz \`order_total\`/\`payment_status\` vazios para o que
7194
+ -- migrou: o financeiro do V1 chega pela fatura (\`invoices\`), que \xE9 outra
7195
+ -- tabela e outro caminho. Costurar as duas \xE9 trabalho de reconcilia\xE7\xE3o, n\xE3o de
7196
+ -- transporte, e faz\xEA-lo aqui inventaria uma Venda que n\xE3o existe.
7197
+ --
7198
+ -- Idempotente.
7199
+
7200
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7201
+ -- 1. O vocabul\xE1rio fechado aprende duas palavras
7202
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7203
+
7204
+ -- A lista era LITERAL aqui, e literal \xE9 o que quebra a corrente: a restri\xE7\xE3o \xE9
7205
+ -- um vocabul\xE1rio fechado, e reescrev\xEA-la por extenso APAGA toda palavra que um
7206
+ -- passo anterior tenha acrescentado depois de algu\xE9m escrever esta lista. Foi o
7207
+ -- que aconteceu com \`fin_cash_session\`, registrado por \`financial/072\`, e com
7208
+ -- \`crm_pipeline\`, que o spine acrescenta antes das cadeias de plugin: a linha j\xE1
7209
+ -- existia no de-para e a restaurada n\xE3o a admitia \u2014
7210
+ -- \`check constraint "allowlist_writer_check" is violated by some row\`, e o
7211
+ -- encadeamento do zero parava aqui.
7212
+ --
7213
+ -- Vira acr\xE9scimo, que \xE9 como \`inventory/004\` j\xE1 fazia: l\xEA a defini\xE7\xE3o vigente,
7214
+ -- costura as duas palavras novas dentro dela e devolve. O que veio antes
7215
+ -- sobrevive, e o passo seguinte herda tudo.
7216
+ DO $$
7217
+ DECLARE v_def text;
7218
+ BEGIN
7219
+ SELECT pg_get_constraintdef(oid) INTO v_def
7220
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
7221
+ IF v_def IS NULL OR strpos(v_def, 'appointment_item') > 0 THEN RETURN; END IF;
7222
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
7223
+ EXECUTE replace(
7224
+ 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
7225
+ '''person''::text',
7226
+ '''person''::text, ''appointment''::text, ''appointment_item''::text');
7227
+ END $$;
7228
+
7229
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7230
+ -- 2. O writer do agendamento
7231
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7232
+
7233
+ CREATE OR REPLACE FUNCTION migration.upsert_appointment(
7234
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
7235
+ ) RETURNS jsonb
7236
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
7237
+ AS $function$
7238
+ DECLARE
7239
+ v_map jsonb := coalesce(p_args -> 'status_map', '{}'::jsonb);
7240
+ v_kind text := coalesce(p_args ->> 'kind', 'appointment');
7241
+ v_src_status text := p_row ->> 'status';
7242
+ v_status text;
7243
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
7244
+ v_starts timestamptz;
7245
+ v_ends timestamptz;
7246
+ v_dur integer;
7247
+ v_party uuid;
7248
+ v_assignee uuid;
7249
+ v_unit uuid;
7250
+ v_existing uuid;
7251
+ v_created boolean := false;
7252
+ v_standalone boolean := migration._standalone();
7253
+ BEGIN
7254
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
7255
+ IF p_id IS NULL THEN
7256
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an appointment needs an id');
7257
+ END IF;
7258
+
7259
+ -- A hora \xE9 o agendamento. Sem ela n\xE3o h\xE1 linha no calend\xE1rio, e uma linha
7260
+ -- sem hora n\xE3o \xE9 um agendamento incompleto: \xE9 outra coisa.
7261
+ v_starts := (p_row ->> 'starts_at')::timestamptz;
7262
+ IF v_starts IS NULL THEN
7263
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'an appointment needs starts_at');
7264
+ END IF;
7265
+
7266
+ IF NOT EXISTS (SELECT 1 FROM public.appointment_kinds k WHERE k.kind = v_kind) THEN
7267
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
7268
+ 'reason', format('appointment kind %L is not declared in public.appointment_kinds', v_kind));
7269
+ END IF;
7270
+
7271
+ v_dur := nullif(p_row ->> 'duration_minutes', '')::integer;
7272
+ v_ends := v_starts + make_interval(mins => coalesce(v_dur, 0));
7273
+
7274
+ v_status := coalesce(v_map ->> coalesce(v_src_status, ''), v_map ->> '*', v_src_status, 'scheduled');
7275
+ -- \`appointment_kinds.statuses\` \xE9 text[], n\xE3o jsonb: o vocabul\xE1rio do tipo \xE9
7276
+ -- uma lista de palavras, e \`= ANY (array)\` \xE9 como se pergunta a ela.
7277
+ IF NOT EXISTS (SELECT 1 FROM public.appointment_kinds k
7278
+ WHERE k.kind = v_kind AND v_status = ANY (k.statuses)) THEN
7279
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
7280
+ 'reason', format('status %L is not one of the statuses declared for kind %L', v_status, v_kind));
7281
+ END IF;
7282
+ v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_status'], to_jsonb(v_src_status));
7283
+
7284
+ BEGIN v_party := (p_row ->> 'party_id')::uuid; EXCEPTION WHEN OTHERS THEN v_party := NULL; END;
7285
+ BEGIN v_assignee := (p_row ->> 'assignee_id')::uuid; EXCEPTION WHEN OTHERS THEN v_assignee := NULL; END;
7286
+ BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
7287
+
7288
+ SELECT tenant_id INTO v_existing FROM public.appointments WHERE id = p_id;
7289
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
7290
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
7291
+ 'reason', format('appointment %s already exists in another tenant', p_id));
7292
+ END IF;
7293
+
7294
+ IF v_existing IS NULL THEN
7295
+ INSERT INTO public.appointments
7296
+ (id, tenant_id, kind, party_id, assignee_id, location_id, unit_id,
7297
+ starts_at, ends_at, status, notes, metadata, checked_in_at, created_at, updated_at)
7298
+ VALUES
7299
+ (p_id, p_tenant, v_kind, v_party, v_assignee, v_unit, v_unit,
7300
+ v_starts, v_ends, v_status, p_row ->> 'notes', v_meta,
7301
+ nullif(p_row ->> 'checked_in_at', '')::timestamptz,
7302
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
7303
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
7304
+ v_created := true;
7305
+ ELSE
7306
+ UPDATE public.appointments SET
7307
+ kind = v_kind,
7308
+ party_id = coalesce(v_party, party_id),
7309
+ assignee_id = coalesce(v_assignee, assignee_id),
7310
+ -- \`location_id\` \xE9 a unidade na leitura (v_appointments junta app.units
7311
+ -- por ela); \`unit_id\` \xE9 a unidade na RLS. As duas apontam para a mesma
7312
+ -- coisa e \xE9 isso que as mant\xE9m verdadeiras juntas.
7313
+ location_id = coalesce(v_unit, location_id),
7314
+ starts_at = v_starts,
7315
+ ends_at = v_ends,
7316
+ status = CASE WHEN p_row ? 'status' THEN v_status ELSE status END,
7317
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
7318
+ checked_in_at = CASE WHEN p_row ? 'checked_in_at'
7319
+ THEN nullif(p_row ->> 'checked_in_at', '')::timestamptz ELSE checked_in_at END,
7320
+ metadata = migration._merge_meta(metadata, v_meta),
7321
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
7322
+ WHERE id = p_id;
7323
+ END IF;
7324
+
7325
+ PERFORM migration._set_unit('public.appointments', p_id, v_unit);
7326
+
7327
+ IF v_standalone THEN
7328
+ PERFORM migration._audit(p_tenant, 'public.appointments', p_id,
7329
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
7330
+ END IF;
7331
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
7332
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
7333
+ END $function$;
7334
+
7335
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7336
+ -- 3. O writer do servi\xE7o agendado
7337
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7338
+
7339
+ CREATE OR REPLACE FUNCTION migration.upsert_appointment_item(
7340
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
7341
+ ) RETURNS jsonb
7342
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
7343
+ AS $function$
7344
+ DECLARE
7345
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
7346
+ v_booking uuid;
7347
+ v_service uuid;
7348
+ v_name text;
7349
+ v_unit uuid;
7350
+ v_existing uuid;
7351
+ v_created boolean := false;
7352
+ v_standalone boolean := migration._standalone();
7353
+ BEGIN
7354
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
7355
+ IF p_id IS NULL THEN
7356
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an appointment item needs an id');
7357
+ END IF;
7358
+
7359
+ BEGIN v_booking := (p_row ->> 'booking_id')::uuid; EXCEPTION WHEN OTHERS THEN v_booking := NULL; END;
7360
+ -- Um item sem agendamento n\xE3o \xE9 um item solto, \xE9 um item perdido. Recusa em
7361
+ -- vez de escrever uma linha que nenhuma tela alcan\xE7a.
7362
+ IF v_booking IS NULL THEN
7363
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
7364
+ 'reason', 'an appointment item needs the appointment it belongs to');
7365
+ END IF;
7366
+
7367
+ BEGIN v_service := (p_row ->> 'service_id')::uuid; EXCEPTION WHEN OTHERS THEN v_service := NULL; END;
7368
+
7369
+ -- O nome \xE9 NOT NULL no destino. A origem guarda o nome no SERVI\xC7O, n\xE3o no
7370
+ -- item, ent\xE3o ele vem pela ponte do extrator; se faltar, o nome do produto
7371
+ -- que a refer\xEAncia resolveu serve, e s\xF3 ent\xE3o o r\xF3tulo gen\xE9rico.
7372
+ v_name := coalesce(
7373
+ nullif(p_row ->> 'name', ''),
7374
+ (SELECT pr.name FROM public.products pr WHERE pr.id = v_service),
7375
+ 'Servi\xE7o');
7376
+
7377
+ SELECT a.unit_id INTO v_unit FROM public.appointments a WHERE a.id = v_booking;
7378
+
7379
+ SELECT tenant_id INTO v_existing FROM public.appointment_items WHERE id = p_id;
7380
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
7381
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
7382
+ 'reason', format('appointment item %s already exists in another tenant', p_id));
7383
+ END IF;
7384
+
7385
+ IF v_existing IS NULL THEN
7386
+ INSERT INTO public.appointment_items
7387
+ (id, tenant_id, booking_id, service_id, name, duration_minutes, price,
7388
+ sort_order, unit_id, metadata, created_at, updated_at)
7389
+ VALUES
7390
+ (p_id, p_tenant, v_booking, v_service, v_name,
7391
+ nullif(p_row ->> 'duration_minutes', '')::integer,
7392
+ coalesce(nullif(p_row ->> 'price', '')::numeric, 0),
7393
+ coalesce(nullif(p_row ->> 'sort_order', '')::integer, 0),
7394
+ v_unit, v_meta,
7395
+ coalesce(nullif(p_row ->> 'created_at', '')::timestamptz, now()),
7396
+ coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now()));
7397
+ v_created := true;
7398
+ ELSE
7399
+ UPDATE public.appointment_items SET
7400
+ booking_id = v_booking,
7401
+ service_id = coalesce(v_service, service_id),
7402
+ name = v_name,
7403
+ duration_minutes = CASE WHEN p_row ? 'duration_minutes'
7404
+ THEN nullif(p_row ->> 'duration_minutes', '')::integer ELSE duration_minutes END,
7405
+ unit_id = coalesce(v_unit, unit_id),
7406
+ metadata = migration._merge_meta(metadata, v_meta),
7407
+ updated_at = coalesce(nullif(p_row ->> 'updated_at', '')::timestamptz, now())
7408
+ WHERE id = p_id;
7409
+ END IF;
7410
+
7411
+ IF v_standalone THEN
7412
+ PERFORM migration._audit(p_tenant, 'public.appointment_items', p_id,
7413
+ jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
7414
+ END IF;
7415
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
7416
+ 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
7417
+ END $function$;
7418
+
7419
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7420
+ -- 4. As duas linhas do de-para
7421
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7422
+ --
7423
+ -- A allowlist congela a linha depois de escrita \u2014 mudar o de-para \xE9 um arquivo
7424
+ -- de migration, n\xE3o um UPDATE avulso, e o gatilho \`trg_allowlist_frozen\` \xE9 quem
7425
+ -- cobra isso. Este \xC9 o arquivo de migration, ent\xE3o ele destrava; \`set_local\`
7426
+ -- para a destrava morrer com a transa\xE7\xE3o, tenha ela terminado bem ou mal.
7427
+
7428
+ SET LOCAL migration.allowlist_unlock = 'on';
7429
+
7430
+ SELECT migration._allow(
7431
+ 'appointments', 'public.appointments', 'agenda', 'appointment',
7432
+ jsonb_build_object(
7433
+ 'kind', 'appointment',
7434
+ 'status_map', jsonb_build_object(
7435
+ 'scheduled', 'scheduled',
7436
+ 'confirmed', 'confirmed',
7437
+ 'in_progress', 'in_progress',
7438
+ 'completed', 'completed',
7439
+ 'no_show', 'no_show',
7440
+ 'unmarked', 'cancelled',
7441
+ 'waiting', 'confirmed')),
7442
+ NULL::text[],
7443
+ jsonb_build_object(
7444
+ 'uuid', 'id',
7445
+ 'appointment_date', 'starts_at',
7446
+ 'duration_minutes', 'duration_minutes',
7447
+ 'status', 'status',
7448
+ 'notes', 'notes',
7449
+ 'checked_in_at', 'checked_in_at',
7450
+ 'created_at', 'created_at',
7451
+ 'updated_at', 'updated_at',
7452
+ 'client_id', 'party_id@clients',
7453
+ 'professional_id', 'assignee_id@professionals',
7454
+ 'unit_company_id', 'unit_id@companies',
7455
+ 'tenant_id', '-',
7456
+ 'checkout_confirmed_by', '-',
7457
+ 'price', 'jsonb:agenda.v1_price',
7458
+ 'is_prepaid', 'jsonb:agenda.is_prepaid',
7459
+ 'prepaid_invoice_item_id', 'jsonb:agenda.v1_prepaid_invoice_item_ref',
7460
+ 'checkout_confirmed_at', 'jsonb:agenda.checkout_confirmed_at',
7461
+ 'service_location_id', 'jsonb:agenda.v1_service_location_ref',
7462
+ 'opportunity_id', 'jsonb:crm.v1_opportunity_ref',
7463
+ 'whatsapp_sent', 'jsonb:agenda.whatsapp_sent',
7464
+ 'whatsapp_confirmed', 'jsonb:agenda.whatsapp_confirmed'),
7465
+ 'O Compromisso do V1. A unidade n\xE3o vem de uma coluna: vem da SALA '
7466
+ '(service_location_id \u2192 service_locations.unit_id \u2192 companies.uuid), e a '
7467
+ 'ponte \xE9 feita no extrator. 99,5% resolvem; os 1.891 sem sala entram sem '
7468
+ 'unidade, que \xE9 o que a origem tem. Sem Venda: order_id fica NULL porque o '
7469
+ 'financeiro do V1 chega pela fatura, que \xE9 outro caminho.'
7470
+ );
7471
+
7472
+ SELECT migration._allow(
7473
+ 'appointment_services', 'public.appointment_items', 'agenda', 'appointment_item',
7474
+ '{}'::jsonb,
7475
+ NULL::text[],
7476
+ jsonb_build_object(
7477
+ 'uuid', 'id',
7478
+ 'appointment_id', 'booking_id@appointments',
7479
+ 'service_id', 'service_id@services',
7480
+ 'service_name', 'name',
7481
+ 'duration_minutes', 'duration_minutes',
7482
+ 'price', 'price',
7483
+ 'created_at', 'created_at',
7484
+ 'updated_at', 'updated_at',
7485
+ 'tenant_id', '-',
7486
+ 'is_revision', 'jsonb:agenda.is_revision',
7487
+ 'revision_number', 'jsonb:agenda.revision_number',
7488
+ 'parent_journey_id', 'jsonb:agenda.v1_parent_journey_ref',
7489
+ 'added_during_execution', 'jsonb:agenda.added_during_execution',
7490
+ 'source_evaluation_execution_id','jsonb:agenda.v1_source_evaluation_ref'),
7491
+ 'O servi\xE7o dentro do Compromisso. \`service_name\` n\xE3o existe no V1 \u2014 vem da '
7492
+ 'ponte do extrator, porque o destino exige nome e a origem guarda o nome no '
7493
+ 'servi\xE7o, n\xE3o na linha. O pre\xE7o \xE9 o pre\xE7o praticado NAQUELE agendamento, e '
7494
+ 'por isso vem cru e n\xE3o da tabela de pre\xE7os.'
7495
+ );
7496
+
7497
+ DELETE FROM migration.excluded_tables WHERE source_table IN ('appointments', 'appointment_services');
7498
+
7499
+ RESET migration.allowlist_unlock;
7500
+
7501
+
7502
+ -- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7503
+ --
7504
+ -- Fun\xE7\xE3o nova nasce execut\xE1vel por PUBLIC, e \`anon\`/\`authenticated\` herdam
7505
+ -- dali. Estas s\xE3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer
7506
+ -- inquilino. S\xF3 o service_role, que \xE9 quem roda a onda.
7507
+ REVOKE ALL ON FUNCTION migration.upsert_appointment(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
7508
+ GRANT EXECUTE ON FUNCTION migration.upsert_appointment(uuid, uuid, jsonb, jsonb) TO service_role;
7509
+ REVOKE ALL ON FUNCTION migration.upsert_appointment_item(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
7510
+ GRANT EXECUTE ON FUNCTION migration.upsert_appointment_item(uuid, uuid, jsonb, jsonb) TO service_role;
7511
+ `;
7512
+ var MIGRATION_009_O_EXECUTOR_SE_ACHA_PELO_PEDIDO = `-- O executor se acha pelo pedido.
7513
+ --
7514
+ -- \`trg_financial_seed_item_participant\` dispara em todo INSERT de
7515
+ -- \`public.items\` e pergunta uma coisa s\xF3:
7516
+ --
7517
+ -- SELECT a.assignee_id, a.unit_id
7518
+ -- FROM public.appointments a
7519
+ -- WHERE a.order_id = NEW.order_id AND a.assignee_id IS NOT NULL
7520
+ -- ORDER BY a.created_at LIMIT 1
7521
+ --
7522
+ -- \xC9 a pergunta certa \u2014 "quem executou o servi\xE7o desta linha?" \u2014, e \xE9 o que
7523
+ -- alimenta a comiss\xE3o. O que faltava era o \xEDndice que a responde:
7524
+ -- \`public.appointments\` tem dez \xEDndices e **nenhum por \`order_id\`**.
7525
+ --
7526
+ -- \u2500\u2500 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7527
+ --
7528
+ -- EXPLAIN ANALYZE de um INSERT em \`public.items\`, tenant Espa\xE7o Facial,
7529
+ -- 20/09/2026, com 179.613 agendamentos na tabela:
7530
+ --
7531
+ -- Trigger items_financial_seed_participant: time=56.799
7532
+ -- ... os outros dezesseis gatilhos somados: time= 5.268
7533
+ -- Execution Time: 62.067 ms
7534
+ --
7535
+ -- **91% do custo de inserir um item.** E o n\xFAmero cresce com a agenda: o mesmo
7536
+ -- INSERT vai custar o dobro quando os 354.208 compromissos do V1 tiverem
7537
+ -- entrado, porque a varredura \xE9 da tabela inteira.
7538
+ --
7539
+ -- \u2500\u2500 N\xE3o \xE9 s\xF3 da migra\xE7\xE3o \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7540
+ --
7541
+ -- Vale registrar, porque o \xEDndice se paga duas vezes: este gatilho roda toda
7542
+ -- vez que algu\xE9m acrescenta uma linha a uma venda no app \u2014 a comanda, o
7543
+ -- carrinho, o atendimento que ganha um produto no fim. Numa rede com agenda
7544
+ -- cheia, cada item somava dezenas de milissegundos de varredura que ningu\xE9m
7545
+ -- pediu. O porte s\xF3 foi quem gritou primeiro: 220.462 itens a 62 ms s\xE3o quatro
7546
+ -- horas.
7547
+ --
7548
+ -- \u2500\u2500 Por que PARCIAL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7549
+ --
7550
+ -- O compromisso do V1 n\xE3o tem Venda: ele chega com \`order_id\` NULL, e s\xE3o
7551
+ -- centenas de milhares assim. Um \xEDndice completo carregaria todos eles para
7552
+ -- responder sobre os poucos que t\xEAm pedido. O parcial indexa s\xF3 as linhas que
7553
+ -- a consulta pode encontrar \u2014 e o planejador o usa porque o predicado
7554
+ -- (\`order_id = \u2026\`) implica a condi\xE7\xE3o do \xEDndice.
7555
+ --
7556
+ -- \`CONCURRENTLY\` porque o app est\xE1 no ar: a forma normal tranca a escrita em
7557
+ -- \`appointments\` enquanto constr\xF3i, e uma recep\xE7\xE3o marcando hor\xE1rio n\xE3o tem
7558
+ -- por que esperar por um \xEDndice.
7559
+ --
7560
+ -- Idempotente.
7561
+
7562
+ -- Sem CONCURRENTLY: \xE9 o \xFAnico arquivo do reposit\xF3rio que o usava, e ele n\xE3o
7563
+ -- roda dentro de transa\xE7\xE3o \u2014 o encadeamento aplica cada migra\xE7\xE3o numa. A
7564
+ -- tabela \xE9 pequena o bastante para o bloqueio curto de um CREATE INDEX comum.
7565
+ CREATE INDEX IF NOT EXISTS appointments_order_idx
7566
+ ON public.appointments (order_id)
7567
+ WHERE order_id IS NOT NULL;
7568
+
7569
+ COMMENT ON INDEX public.appointments_order_idx IS
7570
+ 'O caminho de "quem executou o servi\xE7o desta linha da venda", que '
7571
+ 'trg_financial_seed_item_participant percorre em todo INSERT de items. Sem '
7572
+ 'ele a pergunta era varredura da agenda inteira: 56,8 ms dos 62 ms de '
7573
+ 'inserir um item, medido com 179.613 compromissos.';
7574
+ `;
6930
7575
  var MIGRATIONS = [
6931
7576
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
6932
7577
  { id: "001_the_connector_definers_keep_a_pinned_path", sql: MIGRATION_001_THE_CONNECTOR_DEFINERS_KEEP_A_PINNED_PATH },
@@ -6934,7 +7579,10 @@ var MIGRATIONS = [
6934
7579
  { id: "003_the_client_arrives_before_being_served", sql: MIGRATION_003_THE_CLIENT_ARRIVES_BEFORE_BEING_SERVED },
6935
7580
  { id: "004_the_appointment_carries_its_unit", sql: MIGRATION_004_THE_APPOINTMENT_CARRIES_ITS_UNIT },
6936
7581
  { id: "005_the_queue_knows_how_long_she_waited", sql: MIGRATION_005_THE_QUEUE_KNOWS_HOW_LONG_SHE_WAITED },
6937
- { id: "006_the_agenda_stops_pricing_every_invoice", sql: MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE }
7582
+ { id: "006_the_agenda_stops_pricing_every_invoice", sql: MIGRATION_006_THE_AGENDA_STOPS_PRICING_EVERY_INVOICE },
7583
+ { id: "007_o_cascade_levou_a_agenda_junto", sql: MIGRATION_007_O_CASCADE_LEVOU_A_AGENDA_JUNTO },
7584
+ { id: "008_a_agenda_do_v1_atravessa", sql: MIGRATION_008_A_AGENDA_DO_V1_ATRAVESSA },
7585
+ { id: "009_o_executor_se_acha_pelo_pedido", sql: MIGRATION_009_O_EXECUTOR_SE_ACHA_PELO_PEDIDO }
6938
7586
  ];
6939
7587
  function computeCommissionAmount(total, ratePercent) {
6940
7588
  const base = Number(total) || 0;