@fayz-ai/db 0.12.0 → 0.13.0

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.
Files changed (116) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/040_resource_grants.sql +2 -2
  4. package/migrations/041_scoped_columns.sql +2 -2
  5. package/migrations/042_unit_scope_policies.sql +1 -1
  6. package/migrations/043_view_invoker.sql +12 -3
  7. package/migrations/044_unit_member_facts.sql +1 -1
  8. package/migrations/045_unit_entry.sql +1 -1
  9. package/migrations/046_membership_visible_to_members.sql +1 -1
  10. package/migrations/047_tasks.sql +4 -4
  11. package/migrations/048_every_login_is_a_person.sql +1 -1
  12. package/migrations/049_bookable_people.sql +1 -1
  13. package/migrations/050_authz_core.sql +230 -0
  14. package/migrations/051_authz_helpers.sql +330 -0
  15. package/migrations/052_authz_fanout.sql +253 -0
  16. package/migrations/053_authz_catalog_templates.sql +402 -0
  17. package/migrations/060_tenancy_seam.sql +132 -0
  18. package/migrations/061_create_tenant.sql +125 -0
  19. package/migrations/062_invitations.sql +329 -0
  20. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  21. package/migrations/064_agent_guard_authz.sql +118 -0
  22. package/migrations/064b_supersede_unit_scope.sql +205 -0
  23. package/migrations/065_scaffold_sweep.sql +139 -0
  24. package/migrations/066_shadow_report.sql +122 -0
  25. package/migrations/070_permission_catalog.sql +205 -0
  26. package/migrations/071_v1_permission_map.sql +266 -0
  27. package/migrations/072_role_rpcs.sql +126 -0
  28. package/migrations/073_legacy_rbac_retire.sql +471 -0
  29. package/migrations/075_company_legal_entities.sql +169 -0
  30. package/migrations/076_tenant_settings.sql +251 -0
  31. package/migrations/080_people_roles.sql +269 -0
  32. package/migrations/081_people_identity.sql +271 -0
  33. package/migrations/082_people_membership_link.sql +142 -0
  34. package/migrations/083_people_data_migration.sql +75 -0
  35. package/migrations/084_people_registrations_tokens.sql +282 -0
  36. package/migrations/085_catalog_products.sql +333 -0
  37. package/migrations/086_catalog_pricing.sql +194 -0
  38. package/migrations/087_catalog_packages.sql +134 -0
  39. package/migrations/090_orders_venda.sql +166 -0
  40. package/migrations/091_orders_scaffold.sql +92 -0
  41. package/migrations/095_config_field_rules.sql +261 -0
  42. package/migrations/096_audit_append_only.sql +256 -0
  43. package/migrations/097_sequences_authorized.sql +111 -0
  44. package/migrations/098_holidays.sql +80 -0
  45. package/migrations/099_access_grants.sql +293 -0
  46. package/migrations/100_migration_schema.sql +217 -0
  47. package/migrations/101_migration_allowlist.sql +202 -0
  48. package/migrations/102_migration_rpcs.sql +933 -0
  49. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  50. package/migrations/105_policies_to_authenticated.sql +71 -0
  51. package/migrations/106_function_search_path.sql +55 -0
  52. package/migrations/107_core_table_prefix.sql +361 -0
  53. package/migrations/108_authz_write_guards.sql +149 -0
  54. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  55. package/migrations/110_people_kind_vocabulary.sql +120 -0
  56. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  57. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  58. package/migrations/112_money_precision_standard.sql +132 -0
  59. package/migrations/113_addresses_units_companies.sql +114 -0
  60. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  61. package/migrations/115_registration_honours_field_rules.sql +172 -0
  62. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  63. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  64. package/migrations/118_migration_catalog_writers.sql +450 -0
  65. package/migrations/119_migration_config_writers.sql +193 -0
  66. package/migrations/120_migration_facet_writer.sql +139 -0
  67. package/migrations/121_migration_financial_writers.sql +294 -0
  68. package/migrations/122_migration_inventory_writers.sql +245 -0
  69. package/migrations/123_migration_order_item_writer.sql +144 -0
  70. package/migrations/124_migration_ledger_writers.sql +170 -0
  71. package/migrations/125_migration_authz_people_writers.sql +205 -0
  72. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  73. package/migrations/127_scaffold_audit_hook.sql +98 -0
  74. package/migrations/128_shadow_tables_enforced.sql +93 -0
  75. package/migrations/129_order_kind_terminal_states.sql +57 -0
  76. package/migrations/130_every_login_is_a_person.sql +196 -0
  77. package/migrations/131_migration_invoice_split.sql +127 -0
  78. package/migrations/132_audit_prune_scheduled.sql +28 -0
  79. package/migrations/133_unit_is_a_session_property.sql +155 -0
  80. package/migrations/134_field_rules_on_read.sql +341 -0
  81. package/migrations/135_distribution_polarity.sql +158 -0
  82. package/migrations/136_unit_id_means_locality.sql +358 -0
  83. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  84. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  85. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  86. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  87. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  88. package/migrations/142_a_person_has_addresses.sql +212 -0
  89. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  90. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  91. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  92. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  93. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  94. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  95. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  96. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  97. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  98. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  99. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  100. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  101. package/migrations/155_a_tenant_can_leave.sql +271 -0
  102. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  103. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  104. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  105. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  106. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  107. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  108. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  109. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  110. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  111. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  112. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  113. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  114. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  115. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  116. package/package.json +3 -2
@@ -0,0 +1,933 @@
1
+ -- ============================================================================
2
+ -- 102_migration_rpcs.sql — the destination RPCs of the migration contract
3
+ -- (PRD 10 / #148): batches, fence + kill switch (ADR 0008), the generic
4
+ -- upsert-by-uuid entry point (ADR 0007), the column mapper, and the typed
5
+ -- writers for the core-A destinations that exist today (person, unit,
6
+ -- category, product, order; invoice guarded on the financial slice's table).
7
+ --
8
+ -- Every function: SECURITY DEFINER SET search_path = '', executable by
9
+ -- service_role only (the extractor's credential). None relies on auth.uid():
10
+ -- the actor is the service, the tenant comes from the batch. Business rules:
11
+ -- • allowlist first (unknown/excluded table ⇒ quarantine 'allowlist')
12
+ -- • fence second (not fenced / kill switch ⇒ raise, nothing recorded)
13
+ -- • id = source uuid when present, else minted once and kept in the ledger
14
+ -- • same source row + same checksum ⇒ 'skipped' with no effect; older cursor
15
+ -- ⇒ 'skipped: stale cursor'; changed row ⇒ upsert unless a financial
16
+ -- value would change (⇒ quarantine 'value', destination untouched)
17
+ -- • ambiguity of tenant / identity (CPF) / state / value / fk ⇒ quarantine,
18
+ -- never a silent correction
19
+ -- • every effective write lands an audit_logs row (action migration.upsert)
20
+ -- ============================================================================
21
+
22
+ -- ─────────────────────────────── small helpers ─────────────────────────
23
+ CREATE OR REPLACE FUNCTION migration._has_column(p_table text, p_col text) RETURNS boolean
24
+ LANGUAGE sql STABLE SET search_path = '' AS $$
25
+ SELECT to_regclass(p_table) IS NOT NULL AND EXISTS (
26
+ SELECT 1 FROM pg_attribute a WHERE a.attrelid = to_regclass(p_table) AND a.attname = p_col AND a.attnum > 0 AND NOT a.attisdropped)
27
+ $$;
28
+
29
+ -- Cursors are opaque strings ordered lexicographically (ISO-8601 UTC timestamps,
30
+ -- zero-padded sequences); two all-digit cursors compare numerically.
31
+ CREATE OR REPLACE FUNCTION migration.cursor_cmp(a text, b text) RETURNS integer
32
+ LANGUAGE sql IMMUTABLE AS $$
33
+ SELECT CASE
34
+ WHEN a IS NULL AND b IS NULL THEN 0
35
+ WHEN a IS NULL THEN -1
36
+ WHEN b IS NULL THEN 1
37
+ WHEN a ~ '^\d+$' AND b ~ '^\d+$' THEN sign(a::numeric - b::numeric)::integer
38
+ WHEN a < b THEN -1 WHEN a > b THEN 1 ELSE 0 END
39
+ $$;
40
+
41
+ CREATE OR REPLACE FUNCTION migration._deep_set(p_target jsonb, p_path text[], p_value jsonb) RETURNS jsonb
42
+ LANGUAGE plpgsql IMMUTABLE AS $$
43
+ DECLARE v_child jsonb;
44
+ BEGIN
45
+ IF p_path IS NULL OR array_length(p_path, 1) IS NULL THEN RETURN p_target; END IF;
46
+ IF array_length(p_path, 1) = 1 THEN
47
+ RETURN jsonb_set(coalesce(p_target, '{}'::jsonb), p_path, p_value, true);
48
+ END IF;
49
+ v_child := coalesce(p_target -> p_path[1], '{}'::jsonb);
50
+ IF jsonb_typeof(v_child) <> 'object' THEN v_child := '{}'::jsonb; END IF;
51
+ RETURN jsonb_set(coalesce(p_target, '{}'::jsonb), p_path[1:1], migration._deep_set(v_child, p_path[2:], p_value), true);
52
+ END $$;
53
+
54
+ -- metadata merge: incoming keys win, except metadata.migration where roles are
55
+ -- unioned and merged_from is concatenated (a merged Person keeps every origin).
56
+ CREATE OR REPLACE FUNCTION migration._merge_meta(p_existing jsonb, p_incoming jsonb) RETURNS jsonb
57
+ LANGUAGE plpgsql IMMUTABLE AS $$
58
+ DECLARE
59
+ v jsonb := coalesce(p_existing, '{}'::jsonb) || coalesce(p_incoming, '{}'::jsonb);
60
+ v_m jsonb;
61
+ v_roles jsonb;
62
+ v_from jsonb;
63
+ BEGIN
64
+ IF (p_existing ? 'migration') AND (p_incoming ? 'migration') THEN
65
+ v_m := (p_existing -> 'migration') || (p_incoming -> 'migration');
66
+ SELECT coalesce(jsonb_agg(DISTINCT r ORDER BY r), '[]'::jsonb) INTO v_roles
67
+ FROM (SELECT jsonb_array_elements_text(coalesce(p_existing #> '{migration,roles}', '[]'::jsonb)) r
68
+ UNION SELECT jsonb_array_elements_text(coalesce(p_incoming #> '{migration,roles}', '[]'::jsonb))) s;
69
+ v_from := coalesce(p_existing #> '{migration,merged_from}', '[]'::jsonb) || coalesce(p_incoming #> '{migration,merged_from}', '[]'::jsonb);
70
+ IF jsonb_array_length(v_roles) > 0 THEN v_m := v_m || jsonb_build_object('roles', v_roles); END IF;
71
+ IF jsonb_array_length(v_from) > 0 THEN v_m := v_m || jsonb_build_object('merged_from', v_from); END IF;
72
+ v := jsonb_set(v, '{migration}', v_m, true);
73
+ END IF;
74
+ RETURN v;
75
+ END $$;
76
+
77
+ -- The one hq per tenant / any other index the destination enforces is a state
78
+ -- the migration must not resolve on its own; the writers convert those into
79
+ -- quarantines. This sets unit_id only where the scaffold added the column.
80
+ CREATE OR REPLACE FUNCTION migration._set_unit(p_table text, p_id uuid, p_unit uuid) RETURNS void
81
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
82
+ BEGIN
83
+ IF p_unit IS NULL OR NOT migration._has_column(p_table, 'unit_id') THEN RETURN; END IF;
84
+ EXECUTE format('UPDATE %s SET unit_id = $1 WHERE id = $2', p_table) USING p_unit, p_id;
85
+ END $$;
86
+
87
+ CREATE OR REPLACE FUNCTION migration._destination_values(p_table text, p_id uuid, p_cols text[]) RETURNS jsonb
88
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
89
+ DECLARE v jsonb; v_out jsonb;
90
+ BEGIN
91
+ IF p_id IS NULL OR to_regclass(p_table) IS NULL THEN RETURN NULL; END IF;
92
+ EXECUTE format('SELECT to_jsonb(t) FROM %s t WHERE t.id = $1', p_table) INTO v USING p_id;
93
+ IF v IS NULL THEN RETURN NULL; END IF;
94
+ SELECT coalesce(jsonb_object_agg(c, v -> c), '{}'::jsonb) INTO v_out FROM unnest(coalesce(p_cols, '{}')) c WHERE v ? c;
95
+ RETURN v_out;
96
+ END $$;
97
+
98
+ -- The fence gate every write goes through (ADR 0008).
99
+ CREATE OR REPLACE FUNCTION migration._assert_fence(p_tenant uuid) RETURNS void
100
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
101
+ DECLARE v_state text; v_kill boolean;
102
+ BEGIN
103
+ SELECT state, kill_switch INTO v_state, v_kill FROM migration.tenant_fence WHERE tenant_id = p_tenant;
104
+ IF v_state IS NULL OR v_state NOT IN ('fenced', 'v2_writer') THEN
105
+ RAISE EXCEPTION 'tenant % is not fenced for migration (fence state: %)', p_tenant, coalesce(v_state, 'none')
106
+ USING ERRCODE = '55000', HINT = 'migration.fence_set(tenant, ''fenced'') opens the destination for migration writes.';
107
+ END IF;
108
+ IF v_kill THEN
109
+ RAISE EXCEPTION 'kill switch is on for tenant %: the tenant went back to V1', p_tenant USING ERRCODE = '55000';
110
+ END IF;
111
+ END $$;
112
+
113
+ -- ─────────────────────────────── fence + kill switch ───────────────────
114
+ CREATE OR REPLACE FUNCTION migration.fence_set(p_tenant uuid, p_state text) RETURNS jsonb
115
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
116
+ DECLARE f migration.tenant_fence%ROWTYPE;
117
+ BEGIN
118
+ IF p_state NOT IN ('v1_writer', 'fenced', 'v2_writer') THEN
119
+ RAISE EXCEPTION 'fence_set: state must be v1_writer|fenced|v2_writer (got %)', p_state USING ERRCODE = '22023';
120
+ END IF;
121
+ INSERT INTO migration.tenant_fence (tenant_id) VALUES (p_tenant) ON CONFLICT (tenant_id) DO NOTHING;
122
+ SELECT * INTO f FROM migration.tenant_fence WHERE tenant_id = p_tenant FOR UPDATE;
123
+ IF p_state = 'v1_writer' AND f.first_v2_write_at IS NOT NULL THEN
124
+ RAISE EXCEPTION 'PONR passed for tenant % at %: it cannot return to V1 (ADR 0008)', p_tenant, f.first_v2_write_at USING ERRCODE = '55000';
125
+ END IF;
126
+ IF p_state <> 'v1_writer' AND f.kill_switch THEN
127
+ RAISE EXCEPTION 'kill switch is on for tenant %: turn it off before fencing again', p_tenant USING ERRCODE = '55000';
128
+ END IF;
129
+ UPDATE migration.tenant_fence
130
+ SET state = p_state,
131
+ fenced_at = CASE WHEN p_state = 'fenced' THEN coalesce(fenced_at, now()) ELSE fenced_at END
132
+ WHERE tenant_id = p_tenant
133
+ RETURNING * INTO f;
134
+ RETURN to_jsonb(f);
135
+ END $$;
136
+
137
+ CREATE OR REPLACE FUNCTION migration.kill_switch(p_tenant uuid, p_on boolean) RETURNS jsonb
138
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
139
+ DECLARE f migration.tenant_fence%ROWTYPE;
140
+ BEGIN
141
+ INSERT INTO migration.tenant_fence (tenant_id) VALUES (p_tenant) ON CONFLICT (tenant_id) DO NOTHING;
142
+ SELECT * INTO f FROM migration.tenant_fence WHERE tenant_id = p_tenant FOR UPDATE;
143
+ IF p_on THEN
144
+ IF f.first_v2_write_at IS NOT NULL THEN
145
+ RAISE EXCEPTION 'PONR passed for tenant % at %: the kill switch cannot return it to V1 (ADR 0008)', p_tenant, f.first_v2_write_at USING ERRCODE = '55000';
146
+ END IF;
147
+ UPDATE migration.tenant_fence SET kill_switch = true, state = 'v1_writer' WHERE tenant_id = p_tenant RETURNING * INTO f;
148
+ ELSE
149
+ UPDATE migration.tenant_fence SET kill_switch = false WHERE tenant_id = p_tenant RETURNING * INTO f;
150
+ END IF;
151
+ RETURN to_jsonb(f);
152
+ END $$;
153
+
154
+ -- The point of no return: the first exclusive V2 write of a fenced tenant.
155
+ -- Called by the triggers of 093 (canonical) or by a service that knows it just
156
+ -- wrote as V2. Idempotent; a no-op for tenants without a fence row.
157
+ CREATE OR REPLACE FUNCTION migration.mark_v2_write(p_tenant uuid) RETURNS void
158
+ LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
159
+ UPDATE migration.tenant_fence
160
+ SET first_v2_write_at = now()
161
+ WHERE tenant_id = p_tenant AND first_v2_write_at IS NULL AND state IN ('fenced', 'v2_writer');
162
+ $$;
163
+
164
+ -- ─────────────────────────────── batches ───────────────────────────────
165
+ CREATE OR REPLACE FUNCTION migration.batch_open(p_tenant uuid, p_source_project text, p_cursor_from text DEFAULT NULL, p_source_tenant_id bigint DEFAULT NULL)
166
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
167
+ DECLARE v_id uuid;
168
+ BEGIN
169
+ IF p_source_project IS NULL OR btrim(p_source_project) = '' THEN
170
+ RAISE EXCEPTION 'batch_open: source_project is required' USING ERRCODE = '22023';
171
+ END IF;
172
+ IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant) THEN
173
+ RAISE EXCEPTION 'batch_open: unknown tenant %', p_tenant USING ERRCODE = 'P0002';
174
+ END IF;
175
+ INSERT INTO migration.batches (tenant_id, source_project, source_tenant_id, cursor_from)
176
+ VALUES (p_tenant, p_source_project, p_source_tenant_id, p_cursor_from)
177
+ RETURNING id INTO v_id;
178
+ RETURN v_id;
179
+ END $$;
180
+
181
+ CREATE OR REPLACE FUNCTION migration.batch_pause(p_batch uuid) RETURNS jsonb
182
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
183
+ DECLARE b migration.batches%ROWTYPE;
184
+ BEGIN
185
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch FOR UPDATE;
186
+ IF NOT FOUND THEN RAISE EXCEPTION 'batch_pause: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
187
+ IF b.status <> 'running' THEN RAISE EXCEPTION 'batch_pause: batch % is % (only a running batch pauses)', p_batch, b.status USING ERRCODE = '55000'; END IF;
188
+ UPDATE migration.batches SET status = 'paused' WHERE id = p_batch RETURNING * INTO b;
189
+ RETURN to_jsonb(b);
190
+ END $$;
191
+
192
+ CREATE OR REPLACE FUNCTION migration.batch_resume(p_batch uuid) RETURNS jsonb
193
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
194
+ DECLARE b migration.batches%ROWTYPE;
195
+ BEGIN
196
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch FOR UPDATE;
197
+ IF NOT FOUND THEN RAISE EXCEPTION 'batch_resume: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
198
+ IF b.status <> 'paused' THEN RAISE EXCEPTION 'batch_resume: batch % is % (only a paused batch resumes)', p_batch, b.status USING ERRCODE = '55000'; END IF;
199
+ UPDATE migration.batches SET status = 'running' WHERE id = p_batch RETURNING * INTO b;
200
+ RETURN to_jsonb(b);
201
+ END $$;
202
+
203
+ CREATE OR REPLACE FUNCTION migration.batch_close(p_batch uuid, p_status text DEFAULT 'done') RETURNS jsonb
204
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
205
+ DECLARE b migration.batches%ROWTYPE; v_counts jsonb; v_to text;
206
+ BEGIN
207
+ IF p_status NOT IN ('done', 'failed') THEN RAISE EXCEPTION 'batch_close: status must be done|failed' USING ERRCODE = '22023'; END IF;
208
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch FOR UPDATE;
209
+ IF NOT FOUND THEN RAISE EXCEPTION 'batch_close: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
210
+ IF b.status NOT IN ('running', 'paused') THEN RAISE EXCEPTION 'batch_close: batch % is already %', p_batch, b.status USING ERRCODE = '55000'; END IF;
211
+ SELECT coalesce(jsonb_object_agg(status, n), '{}'::jsonb) INTO v_counts
212
+ FROM (SELECT status, count(*) AS n FROM migration.ledger WHERE batch_id = p_batch GROUP BY status) s;
213
+ SELECT cursor INTO v_to FROM migration.ledger WHERE batch_id = p_batch AND cursor IS NOT NULL
214
+ ORDER BY CASE WHEN cursor ~ '^\d+$' THEN cursor::numeric END DESC NULLS LAST, cursor DESC LIMIT 1;
215
+ UPDATE migration.batches
216
+ SET status = p_status, finished_at = now(), cursor_to = coalesce(v_to, cursor_to),
217
+ counts = counts || jsonb_build_object('ledger', v_counts)
218
+ WHERE id = p_batch RETURNING * INTO b;
219
+ RETURN to_jsonb(b);
220
+ END $$;
221
+
222
+ CREATE OR REPLACE FUNCTION migration._count(p_batch uuid, p_outcome text) RETURNS void
223
+ LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
224
+ UPDATE migration.batches
225
+ SET counts = counts || jsonb_build_object(p_outcome, coalesce((counts ->> p_outcome)::integer, 0) + 1)
226
+ WHERE id = p_batch;
227
+ $$;
228
+
229
+ -- Where does the delta start? The highest cursor the destination has migrated
230
+ -- for that tenant and source table (the extractor asks before each table).
231
+ CREATE OR REPLACE FUNCTION migration.next_cursor(p_tenant uuid, p_source_table text) RETURNS text
232
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$
233
+ SELECT cursor FROM migration.ledger
234
+ WHERE tenant_id = p_tenant AND source_table = p_source_table AND status = 'migrated' AND cursor IS NOT NULL
235
+ ORDER BY CASE WHEN cursor ~ '^\d+$' THEN cursor::numeric END DESC NULLS LAST, cursor DESC
236
+ LIMIT 1;
237
+ $$;
238
+
239
+ -- ─────────────────────────────── references + mapping ──────────────────
240
+ -- A V1 reference (numeric id or uuid) → the destination id through the ledger.
241
+ -- p_target narrows to one destination when a source table splits (order|invoice).
242
+ CREATE OR REPLACE FUNCTION migration.resolve_ref(p_project text, p_source_table text, p_value text, p_target text DEFAULT NULL)
243
+ RETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
244
+ DECLARE v_target text := p_target; v_id uuid;
245
+ BEGIN
246
+ IF p_value IS NULL OR btrim(p_value) = '' THEN RETURN NULL; END IF;
247
+ IF v_target IS NULL THEN SELECT target_table INTO v_target FROM migration.allowlist WHERE source_table = p_source_table; END IF;
248
+ IF p_value ~ '^\d+$' THEN
249
+ SELECT target_id INTO v_id FROM migration.ledger
250
+ WHERE source_project = p_project AND source_table = p_source_table AND source_numeric_id = p_value::bigint
251
+ AND status = 'migrated' AND target_id IS NOT NULL AND (v_target IS NULL OR target_table = v_target)
252
+ LIMIT 1;
253
+ ELSIF p_value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
254
+ SELECT target_id INTO v_id FROM migration.ledger
255
+ WHERE source_project = p_project AND source_table = p_source_table AND source_uuid = p_value::uuid
256
+ AND status = 'migrated' AND target_id IS NOT NULL AND (v_target IS NULL OR target_table = v_target)
257
+ LIMIT 1;
258
+ END IF;
259
+ RETURN v_id;
260
+ END $$;
261
+
262
+ -- Source-shaped row → {row: target-shaped columns, metadata: jsonb paths,
263
+ -- unresolved: [{column, ref_table, ref}]}. Only mapped columns survive (R5).
264
+ CREATE OR REPLACE FUNCTION migration.map_row(p_project text, p_source_table text, p_row jsonb) RETURNS jsonb
265
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$
266
+ DECLARE
267
+ v_map jsonb; k text; spec text; v jsonb;
268
+ v_row jsonb := '{}'::jsonb; v_meta jsonb := '{}'::jsonb; v_unres jsonb := '[]'::jsonb;
269
+ v_col text; v_ref text; v_target text; v_resolved uuid; v_raw text;
270
+ BEGIN
271
+ SELECT columns INTO v_map FROM migration.allowlist WHERE source_table = p_source_table;
272
+ FOR k, spec IN SELECT key, value #>> '{}' FROM jsonb_each(coalesce(v_map, '{}'::jsonb)) LOOP
273
+ v := p_row -> k;
274
+ IF v IS NULL OR spec IS NULL OR spec = '-' THEN CONTINUE; END IF;
275
+ IF spec LIKE 'jsonb:%' THEN
276
+ v_meta := migration._deep_set(v_meta, string_to_array(substr(spec, 7), '.'), v);
277
+ ELSIF position('@' IN spec) > 0 THEN
278
+ IF jsonb_typeof(v) = 'null' THEN CONTINUE; END IF;
279
+ v_col := split_part(spec, '@', 1);
280
+ v_ref := split_part(split_part(spec, '@', 2), ':', 1);
281
+ v_target := nullif(split_part(split_part(spec, '@', 2), ':', 2), '');
282
+ v_raw := v #>> '{}';
283
+ v_resolved := migration.resolve_ref(p_project, v_ref, v_raw, v_target);
284
+ IF v_resolved IS NULL THEN
285
+ v_unres := v_unres || jsonb_build_object('column', v_col, 'ref_table', v_ref, 'ref', v_raw);
286
+ ELSE
287
+ v_row := v_row || jsonb_build_object(v_col, v_resolved);
288
+ END IF;
289
+ ELSE
290
+ v_row := v_row || jsonb_build_object(spec, v);
291
+ END IF;
292
+ END LOOP;
293
+ RETURN jsonb_build_object('row', v_row, 'metadata', v_meta, 'unresolved', v_unres);
294
+ END $$;
295
+
296
+ -- ─────────────────────────────── typed writers ─────────────────────────
297
+ -- Each takes the DESTINATION tenant, the destination id (ADR 0007), a
298
+ -- target-shaped row (map_row output: columns + metadata) and writer args from
299
+ -- the allowlist. Returns {status: migrated|quarantined|skipped, target_id,
300
+ -- reason, kind (quarantine kind), merged, extra: [split rows]}. They are
301
+ -- idempotent by id and callable on their own by service_role (then they check
302
+ -- the fence and audit themselves; through upsert_row the ledger does).
303
+
304
+ -- audit for standalone calls (upsert_row audits with the batch context)
305
+ CREATE OR REPLACE FUNCTION migration._audit(p_tenant uuid, p_target_table text, p_target_id uuid, p_meta jsonb) RETURNS void
306
+ LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
307
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
308
+ VALUES (p_tenant, NULL, 'migration.upsert', p_target_table, p_target_id::text, coalesce(p_meta, '{}'::jsonb));
309
+ $$;
310
+
311
+ CREATE OR REPLACE FUNCTION migration._standalone() RETURNS boolean
312
+ LANGUAGE sql STABLE AS $$ SELECT coalesce(current_setting('migration.batch', true), '') = '' $$;
313
+
314
+ -- Person: one Person per human (people multi-role contract, 070/071). Dedup
315
+ -- by the normalised document (people_normalize_document — the same form the
316
+ -- people slice's partial unique index enforces): one live match ⇒ merge into
317
+ -- the survivor (fill nulls, add the role in person_roles, remember the origin
318
+ -- in metadata.migration.merged_from; the ledger rows point at the survivor);
319
+ -- no document ⇒ new Person, unless someone already shares the same email AND
320
+ -- phone (identity quarantine: merge only by exact document, PRD 03 R2). Roles
321
+ -- are person_roles rows; people.kind is derived by the people slice's triggers.
322
+ CREATE OR REPLACE FUNCTION migration._ensure_role(p_tenant uuid, p_person uuid, p_role text) RETURNS void
323
+ LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$
324
+ INSERT INTO public.person_roles (tenant_id, person_id, role, is_primary)
325
+ VALUES (p_tenant, p_person, p_role,
326
+ NOT EXISTS (SELECT 1 FROM public.person_roles r WHERE r.tenant_id = p_tenant AND r.person_id = p_person AND r.is_primary))
327
+ ON CONFLICT DO NOTHING;
328
+ $$;
329
+
330
+ CREATE OR REPLACE FUNCTION migration.upsert_person(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
331
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
332
+ DECLARE
333
+ v_role text := coalesce(p_args ->> 'role', 'customer');
334
+ v_doc text := public.people_normalize_document(p_row ->> 'document_number');
335
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
336
+ v_origin jsonb;
337
+ v_existing_tenant uuid;
338
+ v_tombstone uuid;
339
+ v_candidates uuid[];
340
+ v_n integer;
341
+ v_survivor uuid;
342
+ v_standalone boolean := migration._standalone();
343
+ BEGIN
344
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
345
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
346
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a person needs an id and a name');
347
+ END IF;
348
+ IF v_role NOT IN ('customer', 'professional', 'employee', 'contact', 'supplier', 'lead') THEN
349
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('unknown person role %s', v_role));
350
+ END IF;
351
+ v_origin := coalesce(v_meta -> 'migration', '{}'::jsonb) - 'merged_from';
352
+
353
+ SELECT tenant_id, merged_into_id INTO v_existing_tenant, v_tombstone FROM public.people WHERE id = p_id;
354
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
355
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('person %s already exists in another tenant', p_id));
356
+ END IF;
357
+ IF v_tombstone IS NOT NULL THEN
358
+ -- the row was merged in V2 after it landed: the human's decision stands, the source now maps to the survivor
359
+ PERFORM migration._ensure_role(p_tenant, v_tombstone, v_role);
360
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_tombstone, 'reason', 'merged', 'merged', true);
361
+ END IF;
362
+
363
+ IF v_existing_tenant IS NOT NULL THEN
364
+ -- the same source row again (delta): mapped columns override, the rest stays
365
+ UPDATE public.people SET
366
+ name = p_row ->> 'name',
367
+ email = CASE WHEN p_row ? 'email' THEN p_row ->> 'email' ELSE email END,
368
+ phone = CASE WHEN p_row ? 'phone' THEN p_row ->> 'phone' ELSE phone END,
369
+ document_number = CASE WHEN p_row ? 'document_number' THEN v_doc ELSE document_number END,
370
+ avatar_url = CASE WHEN p_row ? 'avatar_url' THEN p_row ->> 'avatar_url' ELSE avatar_url END,
371
+ date_of_birth = CASE WHEN p_row ? 'date_of_birth' THEN (p_row ->> 'date_of_birth')::date ELSE date_of_birth END,
372
+ address = CASE WHEN p_row ? 'address' THEN p_row ->> 'address' ELSE address END,
373
+ city = CASE WHEN p_row ? 'city' THEN p_row ->> 'city' ELSE city END,
374
+ state = CASE WHEN p_row ? 'state' THEN p_row ->> 'state' ELSE state END,
375
+ country = CASE WHEN p_row ? 'country' THEN p_row ->> 'country' ELSE country END,
376
+ postal_code = CASE WHEN p_row ? 'postal_code' THEN p_row ->> 'postal_code' ELSE postal_code END,
377
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
378
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
379
+ metadata = migration._merge_meta(metadata, v_meta)
380
+ WHERE id = p_id;
381
+ PERFORM migration._ensure_role(p_tenant, p_id, v_role);
382
+ PERFORM migration._set_unit('public.people', p_id, (p_row ->> 'unit_id')::uuid);
383
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', p_id, jsonb_build_object('reason', 'updated')); END IF;
384
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'updated');
385
+ END IF;
386
+
387
+ IF v_doc IS NOT NULL THEN
388
+ SELECT array_agg(id) INTO v_candidates FROM public.people
389
+ WHERE tenant_id = p_tenant AND document_number = v_doc AND merged_into_id IS NULL;
390
+ ELSIF nullif(btrim(coalesce(p_row ->> 'email', '')), '') IS NOT NULL AND nullif(public.fayz_digits(p_row ->> 'phone'), '') IS NOT NULL THEN
391
+ SELECT array_agg(id) INTO v_candidates FROM public.people
392
+ WHERE tenant_id = p_tenant AND merged_into_id IS NULL AND lower(email) = lower(p_row ->> 'email')
393
+ AND public.fayz_digits(phone) = public.fayz_digits(p_row ->> 'phone');
394
+ END IF;
395
+ v_n := coalesce(array_length(v_candidates, 1), 0);
396
+
397
+ IF v_doc IS NOT NULL AND v_n >= 2 THEN
398
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'identity',
399
+ 'reason', format('ambiguous document: %s existing people share document %s', v_n, v_doc), 'candidates', to_jsonb(v_candidates));
400
+ END IF;
401
+ IF v_doc IS NULL AND v_n >= 1 THEN
402
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'identity',
403
+ 'reason', format('no document: %s existing person(s) share the same email and phone — merge only by exact document', v_n), 'candidates', to_jsonb(v_candidates));
404
+ END IF;
405
+
406
+ IF v_doc IS NOT NULL AND v_n = 1 THEN
407
+ v_survivor := v_candidates[1];
408
+ UPDATE public.people SET
409
+ email = coalesce(email, p_row ->> 'email'),
410
+ phone = coalesce(phone, p_row ->> 'phone'),
411
+ avatar_url = coalesce(avatar_url, p_row ->> 'avatar_url'),
412
+ date_of_birth = coalesce(date_of_birth, (p_row ->> 'date_of_birth')::date),
413
+ address = coalesce(address, p_row ->> 'address'),
414
+ city = coalesce(city, p_row ->> 'city'),
415
+ state = coalesce(state, p_row ->> 'state'),
416
+ country = coalesce(country, p_row ->> 'country'),
417
+ postal_code = coalesce(postal_code, p_row ->> 'postal_code'),
418
+ notes = coalesce(notes, p_row ->> 'notes'),
419
+ metadata = migration._merge_meta(metadata,
420
+ (v_meta - 'migration') || jsonb_build_object('migration', jsonb_build_object('merged_from', jsonb_build_array(v_origin))))
421
+ WHERE id = v_survivor;
422
+ PERFORM migration._ensure_role(p_tenant, v_survivor, v_role);
423
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', v_survivor, jsonb_build_object('reason', 'merged', 'origin', v_origin)); END IF;
424
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_survivor, 'reason', 'merged', 'merged', true);
425
+ END IF;
426
+
427
+ -- kind is written for compatibility only; the role row below is the truth
428
+ -- (fayz.people_kind_sync = on keeps the kind→role trigger quiet meanwhile)
429
+ PERFORM set_config('fayz.people_kind_sync', 'on', true);
430
+ INSERT INTO public.people (id, tenant_id, kind, name, email, phone, document_number, avatar_url, date_of_birth,
431
+ address, city, state, country, postal_code, notes, is_active, metadata)
432
+ VALUES (p_id, p_tenant, coalesce(public.person_role_to_kind(v_role), v_role), p_row ->> 'name', p_row ->> 'email', p_row ->> 'phone', v_doc,
433
+ p_row ->> 'avatar_url', (p_row ->> 'date_of_birth')::date, p_row ->> 'address', p_row ->> 'city', p_row ->> 'state',
434
+ coalesce(p_row ->> 'country', 'BR'), p_row ->> 'postal_code', p_row ->> 'notes',
435
+ coalesce((p_row ->> 'is_active')::boolean, true), v_meta);
436
+ PERFORM set_config('fayz.people_kind_sync', 'off', true);
437
+ PERFORM migration._ensure_role(p_tenant, p_id, v_role);
438
+ PERFORM migration._set_unit('public.people', p_id, (p_row ->> 'unit_id')::uuid);
439
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.people', p_id, jsonb_build_object('reason', 'created')); END IF;
440
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
441
+ END $$;
442
+
443
+ -- Unit: a V1 company (hq|branch) or location group (grouping branch).
444
+ CREATE OR REPLACE FUNCTION migration.upsert_unit(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
445
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
446
+ DECLARE
447
+ v_kind text;
448
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
449
+ v_existing_tenant uuid;
450
+ v_created boolean := false;
451
+ v_standalone boolean := migration._standalone();
452
+ BEGIN
453
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
454
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
455
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a unit needs an id and a name');
456
+ END IF;
457
+ v_kind := CASE
458
+ WHEN coalesce(p_args ->> 'grouping', 'false') = 'true' THEN 'branch'
459
+ WHEN lower(coalesce(p_row ->> 'kind', '')) IN ('true', 't', 'hq') THEN 'hq'
460
+ WHEN lower(coalesce(p_row ->> 'kind', '')) IN ('false', 'f', 'branch') THEN 'branch'
461
+ ELSE coalesce(p_args ->> 'default_kind', 'branch') END;
462
+ IF coalesce(p_args ->> 'grouping', 'false') = 'true' THEN v_meta := v_meta || jsonb_build_object('grouping', true); END IF;
463
+
464
+ SELECT tenant_id INTO v_existing_tenant FROM app.units WHERE id = p_id;
465
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
466
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('unit %s already exists in another tenant', p_id));
467
+ END IF;
468
+ BEGIN
469
+ IF v_existing_tenant IS NULL THEN
470
+ INSERT INTO app.units (tenant_id, id, kind, parent_unit_id, name, slug, active, metadata)
471
+ VALUES (p_tenant, p_id, v_kind, (p_row ->> 'parent_unit_id')::uuid, p_row ->> 'name', p_row ->> 'slug',
472
+ coalesce((p_row ->> 'active')::boolean, true), v_meta);
473
+ v_created := true;
474
+ ELSE
475
+ UPDATE app.units SET
476
+ kind = v_kind,
477
+ parent_unit_id = CASE WHEN p_row ? 'parent_unit_id' THEN (p_row ->> 'parent_unit_id')::uuid ELSE parent_unit_id END,
478
+ name = p_row ->> 'name',
479
+ slug = CASE WHEN p_row ? 'slug' THEN p_row ->> 'slug' ELSE slug END,
480
+ active = CASE WHEN p_row ? 'active' THEN coalesce((p_row ->> 'active')::boolean, true) ELSE active END,
481
+ metadata = migration._merge_meta(metadata, v_meta)
482
+ WHERE id = p_id;
483
+ END IF;
484
+ EXCEPTION WHEN unique_violation OR foreign_key_violation OR check_violation THEN
485
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
486
+ END;
487
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'app.units', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
488
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
489
+ END $$;
490
+
491
+ -- Category: shared categories(kind) of the catalog.
492
+ CREATE OR REPLACE FUNCTION migration.upsert_category(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
493
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
494
+ DECLARE
495
+ v_kind text := coalesce(p_args ->> 'kind', 'product');
496
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
497
+ v_existing_tenant uuid;
498
+ v_created boolean := false;
499
+ v_standalone boolean := migration._standalone();
500
+ BEGIN
501
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
502
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
503
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a category needs an id and a name');
504
+ END IF;
505
+ SELECT tenant_id INTO v_existing_tenant FROM public.categories WHERE id = p_id;
506
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
507
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('category %s already exists in another tenant', p_id));
508
+ END IF;
509
+ IF v_existing_tenant IS NULL THEN
510
+ INSERT INTO public.categories (id, tenant_id, kind, name, slug, parent_id, icon, color, sort_order, is_active, metadata)
511
+ VALUES (p_id, p_tenant, v_kind, p_row ->> 'name', p_row ->> 'slug', (p_row ->> 'parent_id')::uuid, p_row ->> 'icon', p_row ->> 'color',
512
+ coalesce((p_row ->> 'sort_order')::integer, 0), coalesce((p_row ->> 'is_active')::boolean, true), v_meta);
513
+ v_created := true;
514
+ ELSE
515
+ UPDATE public.categories SET
516
+ kind = v_kind, name = p_row ->> 'name',
517
+ slug = CASE WHEN p_row ? 'slug' THEN p_row ->> 'slug' ELSE slug END,
518
+ parent_id = CASE WHEN p_row ? 'parent_id' THEN (p_row ->> 'parent_id')::uuid ELSE parent_id END,
519
+ icon = CASE WHEN p_row ? 'icon' THEN p_row ->> 'icon' ELSE icon END,
520
+ color = CASE WHEN p_row ? 'color' THEN p_row ->> 'color' ELSE color END,
521
+ sort_order = CASE WHEN p_row ? 'sort_order' THEN (p_row ->> 'sort_order')::integer ELSE sort_order END,
522
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END,
523
+ metadata = migration._merge_meta(metadata, v_meta)
524
+ WHERE id = p_id;
525
+ END IF;
526
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.categories', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
527
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
528
+ END $$;
529
+
530
+ -- Product: catalog item kind good|service (products.kind when the catalog slice
531
+ -- added the column, metadata.catalog.kind always).
532
+ CREATE OR REPLACE FUNCTION migration.upsert_product(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
533
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
534
+ DECLARE
535
+ v_kind text := coalesce(p_args ->> 'kind', 'good');
536
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
537
+ v_existing_tenant uuid;
538
+ v_created boolean := false;
539
+ v_active boolean := coalesce((p_row ->> 'is_active')::boolean, true);
540
+ v_standalone boolean := migration._standalone();
541
+ BEGIN
542
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
543
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
544
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a product needs an id and a name');
545
+ END IF;
546
+ v_meta := migration._deep_set(v_meta, ARRAY['catalog', 'kind'], to_jsonb(v_kind));
547
+ SELECT tenant_id INTO v_existing_tenant FROM public.products WHERE id = p_id;
548
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
549
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('product %s already exists in another tenant', p_id));
550
+ END IF;
551
+ IF v_existing_tenant IS NULL THEN
552
+ INSERT INTO public.products (id, tenant_id, category_id, name, description, sku, price, cost, currency, unit, image_url, status, is_active, metadata)
553
+ VALUES (p_id, p_tenant, (p_row ->> 'category_id')::uuid, p_row ->> 'name', p_row ->> 'description', p_row ->> 'sku',
554
+ (p_row ->> 'price')::numeric, (p_row ->> 'cost')::numeric, coalesce(p_row ->> 'currency', 'BRL'), p_row ->> 'unit',
555
+ p_row ->> 'image_url', CASE WHEN v_active THEN 'active' ELSE 'inactive' END, v_active, v_meta);
556
+ v_created := true;
557
+ ELSE
558
+ UPDATE public.products SET
559
+ name = p_row ->> 'name',
560
+ category_id = CASE WHEN p_row ? 'category_id' THEN (p_row ->> 'category_id')::uuid ELSE category_id END,
561
+ description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
562
+ sku = CASE WHEN p_row ? 'sku' THEN p_row ->> 'sku' ELSE sku END,
563
+ price = CASE WHEN p_row ? 'price' THEN (p_row ->> 'price')::numeric ELSE price END,
564
+ cost = CASE WHEN p_row ? 'cost' THEN (p_row ->> 'cost')::numeric ELSE cost END,
565
+ unit = CASE WHEN p_row ? 'unit' THEN p_row ->> 'unit' ELSE unit END,
566
+ image_url = CASE WHEN p_row ? 'image_url' THEN p_row ->> 'image_url' ELSE image_url END,
567
+ status = CASE WHEN p_row ? 'is_active' THEN (CASE WHEN v_active THEN 'active' ELSE 'inactive' END) ELSE status END,
568
+ is_active = CASE WHEN p_row ? 'is_active' THEN v_active ELSE is_active END,
569
+ metadata = migration._merge_meta(metadata, v_meta)
570
+ WHERE id = p_id;
571
+ END IF;
572
+ IF migration._has_column('public.products', 'kind') THEN
573
+ EXECUTE 'UPDATE public.products SET kind = $1 WHERE id = $2' USING v_kind, p_id;
574
+ END IF;
575
+ PERFORM migration._set_unit('public.products', p_id, (p_row ->> 'unit_id')::uuid);
576
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.products', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
577
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
578
+ END $$;
579
+
580
+ -- Invoice: guarded on the financial slice's table (#141). Absent ⇒ skipped with a
581
+ -- clear reason; present ⇒ insert-only through the columns that exist (a
582
+ -- financial document is never rewritten by the migration).
583
+ CREATE OR REPLACE FUNCTION migration.upsert_invoice(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
584
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
585
+ DECLARE
586
+ v_table text := 'public.plg_financial_invoices';
587
+ v_candidate jsonb;
588
+ v_cols text[] := '{}';
589
+ v_vals text[] := '{}';
590
+ k text; v jsonb;
591
+ v_exists boolean;
592
+ v_standalone boolean := migration._standalone();
593
+ BEGIN
594
+ IF to_regclass(v_table) IS NULL THEN
595
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'destination table absent: ' || v_table);
596
+ END IF;
597
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
598
+ EXECUTE format('SELECT EXISTS (SELECT 1 FROM %s WHERE id = $1)', v_table) INTO v_exists USING p_id;
599
+ IF v_exists THEN
600
+ RETURN jsonb_build_object('status', 'skipped', 'target_id', p_id, 'reason', 'invoice already exists: financial documents are never rewritten by the migration');
601
+ END IF;
602
+ v_candidate := jsonb_build_object(
603
+ 'id', p_id, 'tenant_id', p_tenant,
604
+ 'unit_id', p_row -> 'unit_id', 'order_id', p_row -> 'order_id',
605
+ 'party_id', p_row -> 'party_id', 'counterparty_id', p_row -> 'party_id',
606
+ 'direction', coalesce(p_args ->> 'direction', p_row ->> 'direction', 'receivable'),
607
+ 'kind', coalesce(p_args ->> 'kind', 'receivable'),
608
+ 'total', p_row -> 'total', 'total_amount', p_row -> 'total', 'amount', p_row -> 'total',
609
+ 'status', coalesce(p_row ->> 'status', 'open'),
610
+ 'competence_date', p_row -> 'competence_date', 'due_date', p_row -> 'due_date',
611
+ 'notes', p_row -> 'notes', 'metadata', coalesce(p_row -> 'metadata', '{}'::jsonb));
612
+ FOR k, v IN SELECT key, value FROM jsonb_each(v_candidate) LOOP
613
+ IF v IS NULL OR jsonb_typeof(v) = 'null' THEN CONTINUE; END IF;
614
+ IF migration._has_column(v_table, k) THEN
615
+ v_cols := v_cols || quote_ident(k);
616
+ v_vals := v_vals || format('(%L::jsonb #>> ''{}'')::%s', v, (SELECT format_type(a.atttypid, a.atttypmod) FROM pg_attribute a WHERE a.attrelid = to_regclass(v_table) AND a.attname = k));
617
+ END IF;
618
+ END LOOP;
619
+ EXECUTE format('INSERT INTO %s (%s) VALUES (%s)', v_table, array_to_string(v_cols, ', '), array_to_string(v_vals, ', '));
620
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, v_table, p_id, jsonb_build_object('reason', 'created')); END IF;
621
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
622
+ END $$;
623
+
624
+ -- Order: a V1 invoice as a Venda (ADR 0004). Financial columns are written once;
625
+ -- upsert_row refuses a later change of them (value quarantine). With
626
+ -- split_invoice the receivable invoice is written too when its table exists — a
627
+ -- second ledger row for the same source row (deterministic id, so a replay
628
+ -- reaches the same invoice).
629
+ CREATE OR REPLACE FUNCTION migration.upsert_order(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
630
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
631
+ DECLARE
632
+ v_kind text := coalesce(p_args ->> 'kind', 'sale');
633
+ v_map jsonb := coalesce(p_args -> 'status_map', '{}'::jsonb);
634
+ v_src_status text := p_row ->> 'status';
635
+ v_status text;
636
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
637
+ v_existing_tenant uuid;
638
+ v_created boolean := false;
639
+ v_invoice jsonb;
640
+ v_invoice_id uuid;
641
+ v_extra jsonb := '[]'::jsonb;
642
+ v_standalone boolean := migration._standalone();
643
+ BEGIN
644
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
645
+ IF p_id IS NULL THEN
646
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'an order needs an id');
647
+ END IF;
648
+ v_status := coalesce(v_map ->> coalesce(v_src_status, ''), v_map ->> '*', v_src_status, 'completed');
649
+ v_meta := migration._deep_set(v_meta, ARRAY['migration', 'source_status'], to_jsonb(v_src_status));
650
+
651
+ SELECT tenant_id INTO v_existing_tenant FROM public.orders WHERE id = p_id;
652
+ IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
653
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s already exists in another tenant', p_id));
654
+ END IF;
655
+ IF v_existing_tenant IS NULL THEN
656
+ INSERT INTO public.orders (id, tenant_id, kind, reference_number, status, party_id, subtotal, discount, tax, total, currency, completed_at, notes, metadata)
657
+ VALUES (p_id, p_tenant, v_kind, p_row ->> 'reference_number', v_status, (p_row ->> 'party_id')::uuid,
658
+ (p_row ->> 'subtotal')::numeric, coalesce((p_row ->> 'discount')::numeric, 0), coalesce((p_row ->> 'tax')::numeric, 0),
659
+ (p_row ->> 'total')::numeric, coalesce(p_row ->> 'currency', 'BRL'), (p_row ->> 'completed_at')::timestamptz,
660
+ p_row ->> 'notes', v_meta);
661
+ v_created := true;
662
+ ELSE
663
+ -- financial columns are guarded upstream (upsert_row) and not touched here
664
+ UPDATE public.orders SET
665
+ kind = v_kind,
666
+ reference_number = CASE WHEN p_row ? 'reference_number' THEN p_row ->> 'reference_number' ELSE reference_number END,
667
+ status = CASE WHEN p_row ? 'status' THEN v_status ELSE status END,
668
+ party_id = CASE WHEN p_row ? 'party_id' THEN (p_row ->> 'party_id')::uuid ELSE party_id END,
669
+ completed_at = CASE WHEN p_row ? 'completed_at' THEN (p_row ->> 'completed_at')::timestamptz ELSE completed_at END,
670
+ notes = CASE WHEN p_row ? 'notes' THEN p_row ->> 'notes' ELSE notes END,
671
+ metadata = migration._merge_meta(metadata, v_meta)
672
+ WHERE id = p_id;
673
+ END IF;
674
+ PERFORM migration._set_unit('public.orders', p_id, (p_row ->> 'unit_id')::uuid);
675
+
676
+ IF coalesce(p_args ->> 'split_invoice', 'false') = 'true' THEN
677
+ v_invoice_id := md5('migration:invoice:' || p_id::text)::uuid;
678
+ v_invoice := migration.upsert_invoice(p_tenant, v_invoice_id,
679
+ jsonb_build_object('order_id', p_id, 'party_id', p_row -> 'party_id', 'unit_id', p_row -> 'unit_id', 'total', p_row -> 'total',
680
+ 'competence_date', p_row -> 'completed_at', 'status', 'open', 'metadata', v_meta),
681
+ jsonb_build_object('direction', 'receivable', 'kind', 'receivable'));
682
+ v_extra := v_extra || jsonb_build_object('target_table', 'public.plg_financial_invoices', 'target_id', v_invoice_id,
683
+ 'status', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN 'migrated' ELSE 'skipped' END,
684
+ 'reason', v_invoice ->> 'reason',
685
+ 'financial', CASE WHEN v_invoice ->> 'status' = 'migrated' THEN jsonb_build_object('total', p_row -> 'total') ELSE NULL END);
686
+ END IF;
687
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.orders', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
688
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END, 'extra', v_extra);
689
+ END $$;
690
+
691
+ -- ─────────────────────────────── the entry point ───────────────────────
692
+ CREATE OR REPLACE FUNCTION migration._quarantine(p_ledger_id bigint, p_batch uuid, p_kind text, p_reason text, p_payload jsonb, p_target_table text DEFAULT NULL)
693
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
694
+ DECLARE v_q bigint; v_l migration.ledger%ROWTYPE;
695
+ BEGIN
696
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'superseded by a later offer of the same row'
697
+ WHERE ledger_id = p_ledger_id AND resolved_at IS NULL;
698
+ -- a row already at the destination keeps status 'migrated' (the earlier
699
+ -- version stays); the open quarantine row is what flags it
700
+ UPDATE migration.ledger SET status = CASE WHEN status = 'migrated' THEN 'migrated' ELSE 'quarantined' END,
701
+ reason = p_kind || ': ' || p_reason, batch_id = p_batch, target_table = coalesce(target_table, p_target_table)
702
+ WHERE id = p_ledger_id RETURNING * INTO v_l;
703
+ INSERT INTO migration.quarantine (ledger_id, tenant_id, batch_id, kind, reason, payload)
704
+ VALUES (p_ledger_id, v_l.tenant_id, p_batch, p_kind, p_reason, p_payload) RETURNING id INTO v_q;
705
+ PERFORM migration._count(p_batch, 'quarantined');
706
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', p_kind, 'reason', p_reason, 'target_table', v_l.target_table,
707
+ 'target_id', v_l.target_id, 'ledger_id', p_ledger_id, 'quarantine_id', v_q);
708
+ END $$;
709
+
710
+ CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
711
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
712
+ DECLARE
713
+ b migration.batches%ROWTYPE;
714
+ a migration.allowlist%ROWTYPE;
715
+ l migration.ledger%ROWTYPE;
716
+ v_checksum text := md5(p_row::text);
717
+ v_target_table text;
718
+ v_target_id uuid;
719
+ v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
720
+ v_dest jsonb; v_fin jsonb;
721
+ v_res jsonb; v_status text; v_col text; v_excl text;
722
+ v_retry_split boolean := false;
723
+ x jsonb; v_xid bigint;
724
+ BEGIN
725
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
726
+ IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
727
+ IF b.status <> 'running' THEN
728
+ RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
729
+ END IF;
730
+ PERFORM migration._assert_fence(b.tenant_id);
731
+
732
+ SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
733
+ v_target_table := a.target_table;
734
+
735
+ SELECT * INTO l FROM migration.ledger
736
+ WHERE source_project = b.source_project AND source_table = p_source_table
737
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
738
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
739
+ AND coalesce(target_table, '') = coalesce(v_target_table, '')
740
+ FOR UPDATE;
741
+ IF NOT FOUND THEN
742
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
743
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
744
+ RETURNING * INTO l;
745
+ ELSIF l.tenant_id <> b.tenant_id THEN
746
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
747
+ format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
748
+ END IF;
749
+
750
+ -- allowlist
751
+ IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
752
+ SELECT reason INTO v_excl FROM migration.excluded_tables e WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END;
753
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row);
754
+ END IF;
755
+ IF a.source_table IS NULL THEN
756
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
757
+ END IF;
758
+ -- tenant identity of the source row vs the batch
759
+ IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
760
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
761
+ format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
762
+ END IF;
763
+
764
+ -- idempotency + delta
765
+ IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
766
+ PERFORM migration._count(p_batch, 'skipped');
767
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
768
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
769
+ END IF;
770
+ IF l.status = 'migrated' THEN
771
+ -- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
772
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
773
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
774
+ AND s.source_numeric_id IS NOT DISTINCT FROM l.source_numeric_id AND s.source_uuid IS NOT DISTINCT FROM l.source_uuid
775
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
776
+ IF l.checksum = v_checksum AND NOT v_retry_split THEN
777
+ PERFORM migration._count(p_batch, 'skipped');
778
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
779
+ END IF;
780
+ IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
781
+ PERFORM migration._count(p_batch, 'skipped');
782
+ RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
783
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
784
+ END IF;
785
+ END IF;
786
+
787
+ -- no writer yet: recorded, re-offered later
788
+ IF a.writer IS NULL THEN
789
+ UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
790
+ checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
791
+ PERFORM migration._count(p_batch, 'pending');
792
+ RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
793
+ 'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
794
+ END IF;
795
+
796
+ -- map + references
797
+ v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
798
+ v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
799
+ IF jsonb_array_length(v_unres) > 0 THEN
800
+ RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
801
+ END IF;
802
+
803
+ -- destination id (ADR 0007): the source uuid, else the id minted on the first offer
804
+ v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
805
+ v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
806
+ 'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
807
+ 'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
808
+ v_row := v_row || jsonb_build_object('metadata', v_meta);
809
+ SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
810
+
811
+ -- never overwrite an existing financial value; never re-create a deleted row
812
+ IF l.status = 'migrated' THEN
813
+ IF cardinality(a.financial_columns) > 0 THEN
814
+ v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
815
+ IF v_dest IS NULL THEN
816
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
817
+ END IF;
818
+ FOREACH v_col IN ARRAY a.financial_columns LOOP
819
+ IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
820
+ RETURN migration._quarantine(l.id, p_batch, 'value',
821
+ format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
822
+ END IF;
823
+ END LOOP;
824
+ ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
825
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
826
+ END IF;
827
+ END IF;
828
+
829
+ -- delegate; any error is a state quarantine, never a partial write
830
+ BEGIN
831
+ v_res := CASE a.writer
832
+ WHEN 'person' THEN migration.upsert_person(b.tenant_id, v_target_id, v_row, a.writer_args)
833
+ WHEN 'unit' THEN migration.upsert_unit(b.tenant_id, v_target_id, v_row, a.writer_args)
834
+ WHEN 'category' THEN migration.upsert_category(b.tenant_id, v_target_id, v_row, a.writer_args)
835
+ WHEN 'product' THEN migration.upsert_product(b.tenant_id, v_target_id, v_row, a.writer_args)
836
+ WHEN 'order' THEN migration.upsert_order(b.tenant_id, v_target_id, v_row, a.writer_args)
837
+ WHEN 'invoice' THEN migration.upsert_invoice(b.tenant_id, v_target_id, v_row, a.writer_args)
838
+ END;
839
+ EXCEPTION WHEN OTHERS THEN
840
+ v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
841
+ END;
842
+ v_status := coalesce(v_res ->> 'status', 'quarantined');
843
+
844
+ IF v_status = 'quarantined' THEN
845
+ RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
846
+ END IF;
847
+ IF v_status = 'skipped' THEN
848
+ UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
849
+ target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
850
+ PERFORM migration._count(p_batch, 'skipped');
851
+ RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
852
+ END IF;
853
+
854
+ v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
855
+ UPDATE migration.ledger
856
+ SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
857
+ batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
858
+ WHERE id = l.id;
859
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
860
+
861
+ -- split rows (a Venda's invoice): one more ledger row per extra destination
862
+ FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
863
+ SELECT id INTO v_xid FROM migration.ledger
864
+ WHERE source_project = b.source_project AND source_table = p_source_table
865
+ AND source_numeric_id IS NOT DISTINCT FROM p_source_numeric_id AND source_uuid IS NOT DISTINCT FROM p_source_uuid
866
+ AND target_table = x ->> 'target_table';
867
+ IF v_xid IS NULL THEN
868
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
869
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
870
+ v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
871
+ ELSE
872
+ UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
873
+ status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
874
+ END IF;
875
+ END LOOP;
876
+
877
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
878
+ VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
879
+ jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
880
+ 'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
881
+ PERFORM migration._count(p_batch, 'migrated');
882
+ RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
883
+ 'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
884
+ END $$;
885
+
886
+ -- The generic destination write. Marks the transaction as a migration writer
887
+ -- (so the PONR triggers of 093 stay quiet) for the duration of the call only.
888
+ CREATE OR REPLACE FUNCTION migration.upsert_row(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
889
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
890
+ DECLARE v jsonb;
891
+ BEGIN
892
+ IF p_row IS NULL OR jsonb_typeof(p_row) <> 'object' THEN
893
+ RAISE EXCEPTION 'upsert_row: p_row must be a JSON object (the source row)' USING ERRCODE = '22023';
894
+ END IF;
895
+ IF p_source_numeric_id IS NULL AND p_source_uuid IS NULL THEN
896
+ RAISE EXCEPTION 'upsert_row: a source row needs a numeric id or a uuid' USING ERRCODE = '22023';
897
+ END IF;
898
+ IF p_source_table IS NULL OR btrim(p_source_table) = '' THEN
899
+ RAISE EXCEPTION 'upsert_row: source_table is required' USING ERRCODE = '22023';
900
+ END IF;
901
+ PERFORM set_config('migration.writer', 'on', true);
902
+ PERFORM set_config('migration.batch', p_batch::text, true);
903
+ BEGIN
904
+ v := migration._upsert_row_impl(p_batch, p_source_table, p_source_numeric_id, p_source_uuid, p_cursor, p_row);
905
+ EXCEPTION WHEN OTHERS THEN
906
+ PERFORM set_config('migration.writer', 'off', true);
907
+ PERFORM set_config('migration.batch', '', true);
908
+ RAISE;
909
+ END;
910
+ PERFORM set_config('migration.writer', 'off', true);
911
+ PERFORM set_config('migration.batch', '', true);
912
+ RETURN v;
913
+ END $$;
914
+
915
+ -- ─────────────────────────────── grants ────────────────────────────────
916
+ -- Dedup lookups use the people slice's document index (people_tenant_document_uidx,
917
+ -- 071); the earlier private expression index is retired.
918
+ DROP INDEX IF EXISTS public.people_migration_document_idx;
919
+
920
+ DO $$
921
+ DECLARE r record;
922
+ BEGIN
923
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'migration' LOOP
924
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
925
+ END LOOP;
926
+ END $$;
927
+ GRANT EXECUTE ON FUNCTION migration.cursor_cmp(text, text), migration.fence_set(uuid, text), migration.kill_switch(uuid, boolean),
928
+ migration.mark_v2_write(uuid), migration.batch_open(uuid, text, text, bigint), migration.batch_pause(uuid), migration.batch_resume(uuid),
929
+ migration.batch_close(uuid, text), migration.next_cursor(uuid, text), migration.resolve_ref(text, text, text, text),
930
+ migration.map_row(text, text, jsonb), migration.upsert_person(uuid, uuid, jsonb, jsonb), migration.upsert_unit(uuid, uuid, jsonb, jsonb),
931
+ migration.upsert_category(uuid, uuid, jsonb, jsonb), migration.upsert_product(uuid, uuid, jsonb, jsonb), migration.upsert_order(uuid, uuid, jsonb, jsonb),
932
+ migration.upsert_invoice(uuid, uuid, jsonb, jsonb), migration.upsert_row(uuid, text, bigint, uuid, text, jsonb)
933
+ TO service_role;