@fayz-ai/db 0.11.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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -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;
|