@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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- 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 +3 -2
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 085_catalog_products.sql — the catalog contract, part 1 (PRD 04 / #139):
|
|
3
|
+
-- ONE catalog item archetype. `public.products` carries `kind good|service`
|
|
4
|
+
-- (DATA-MODEL §1: a sixth "services" table was rejected); the drifted
|
|
5
|
+
-- `public.services` table is folded into products and kept as a compatibility
|
|
6
|
+
-- MIRROR; products and categories get the authz scaffold plus the catalog
|
|
7
|
+
-- release rule (available_in).
|
|
8
|
+
--
|
|
9
|
+
-- What this file does, in order (every step idempotent + replay-safe):
|
|
10
|
+
-- 1. products: kind (good|service), subkind (vertical/plugin discriminator),
|
|
11
|
+
-- duration_minutes (core, services), service_kind (ADR 0009 promoted
|
|
12
|
+
-- column: standard|evaluation|return — a schedulable state, not JSONB).
|
|
13
|
+
-- 2. authz scaffold on products, categories and services (mirror), plus the
|
|
14
|
+
-- RESTRICTIVE release policy: a tenant-wide item (unit_id null) is visible
|
|
15
|
+
-- in a unit only if it has no `available_in` tuple or one for that unit
|
|
16
|
+
-- ("no tuples ⇒ everywhere", §9.1); a unit-scoped item is governed by
|
|
17
|
+
-- has_unit as every business row.
|
|
18
|
+
-- 3. a BEFORE trigger normalises writes: legacy `kind` values the fleet
|
|
19
|
+
-- wrote before this contract ('menu_item', 'sale', 'ingredient', 'asset',
|
|
20
|
+
-- 'intermediate', 'course') map to kind + subkind so old TS keeps working;
|
|
21
|
+
-- service_kind is filled for services and cleared for goods. Unknown
|
|
22
|
+
-- kinds are left for the CHECK to reject.
|
|
23
|
+
-- 4. FOLD: every public.services row becomes a products row with the SAME id
|
|
24
|
+
-- (ADR 0007 spirit — order_items.service_id keeps meaning); the FKs of
|
|
25
|
+
-- order_items.service_id and appointment_items.service_id are repointed
|
|
26
|
+
-- to products(id).
|
|
27
|
+
-- 5. COMPAT: public.services stays a physical table (a VIEW would break the
|
|
28
|
+
-- replay of 004/006/025/034, which ALTER/INDEX/POLICY it — applied files
|
|
29
|
+
-- are never edited). It is a projection of products WHERE kind='service'
|
|
30
|
+
-- maintained by triggers in BOTH directions, so `.from('services')` reads
|
|
31
|
+
-- AND writes keep working; a transaction-local flag stops the echo.
|
|
32
|
+
-- Deprecation: Phase 1 makes it read-only, Phase 2 drops it
|
|
33
|
+
-- (docs/data/phase-0/04-catalog-contract.md).
|
|
34
|
+
-- 6. stock/min_stock stay (inventory owns balances, #143) — commented as
|
|
35
|
+
-- deprecated; global search sources split goods and services.
|
|
36
|
+
-- ============================================================================
|
|
37
|
+
|
|
38
|
+
-- ── 1. products columns ─────────────────────────────────────────────────
|
|
39
|
+
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'good';
|
|
40
|
+
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS subkind text;
|
|
41
|
+
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS duration_minutes integer;
|
|
42
|
+
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS service_kind text;
|
|
43
|
+
|
|
44
|
+
COMMENT ON COLUMN public.products.kind IS
|
|
45
|
+
'Catalog archetype: good | service. The only discriminator the core knows (DATA-MODEL §1). Vertical/plugin refinements go in subkind.';
|
|
46
|
+
COMMENT ON COLUMN public.products.subkind IS
|
|
47
|
+
'Vertical or plugin discriminator inside a kind (menu_item, ingredient, asset, intermediate, course, …). Free text: a new subkind is a row, not a migration.';
|
|
48
|
+
COMMENT ON COLUMN public.products.duration_minutes IS
|
|
49
|
+
'Core duration of a service (kind=service); the agenda reads it. NULL for goods.';
|
|
50
|
+
COMMENT ON COLUMN public.products.service_kind IS
|
|
51
|
+
'ADR 0009 promoted column: standard | evaluation | return — a schedulable state (RN-CAT-03), never JSONB. NULL for goods, standard by default for services.';
|
|
52
|
+
COMMENT ON COLUMN public.products.stock IS
|
|
53
|
+
'deprecated: inventory owns balances (#143). Kept only until the inventory slice moves the last reader; never write it from the catalog.';
|
|
54
|
+
COMMENT ON COLUMN public.products.min_stock IS
|
|
55
|
+
'deprecated: inventory owns balances (#143). Kept only until the inventory slice moves the last reader; never write it from the catalog.';
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS products_tenant_kind_idx ON public.products (tenant_id, kind);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS products_tenant_subkind_idx ON public.products (tenant_id, subkind) WHERE subkind IS NOT NULL;
|
|
59
|
+
|
|
60
|
+
-- ── 2. authz scaffold (§4/§7 template) ─────────────────────────────────
|
|
61
|
+
-- Catalog is a tenant-wide resource: unit_id is null by default; owner scoping
|
|
62
|
+
-- off. products / categories / services (mirror) share the 'catalog' keys.
|
|
63
|
+
-- enforce mode replaces the 004/006 tenant-only policies (a replay of 004/006
|
|
64
|
+
-- recreates them; this call converges the table again).
|
|
65
|
+
SELECT app.scaffold_table('public.products', 'catalog.product', 'catalog', false, 'enforce');
|
|
66
|
+
SELECT app.scaffold_table('public.categories', 'catalog.category', 'catalog', false, 'enforce');
|
|
67
|
+
SELECT app.scaffold_table('public.services', 'catalog.product', 'catalog', false, 'enforce');
|
|
68
|
+
|
|
69
|
+
-- The release mechanism (§9.1). Permissive policies OR together, so a
|
|
70
|
+
-- restriction has to be AS RESTRICTIVE: it ANDs with the scaffold's select.
|
|
71
|
+
-- unit-scoped item (unit_id set) → has_unit decides (scaffold), this is true
|
|
72
|
+
-- tenant-wide item (unit_id null) → visible where available_in says; no tuple
|
|
73
|
+
-- ⇒ everywhere
|
|
74
|
+
-- Created AFTER the scaffold (enforce drops every foreign policy) so a replay
|
|
75
|
+
-- converges: scaffold drops it, this recreates it.
|
|
76
|
+
DROP POLICY IF EXISTS products_available_in ON public.products;
|
|
77
|
+
CREATE POLICY products_available_in ON public.products AS RESTRICTIVE FOR SELECT TO authenticated
|
|
78
|
+
USING (unit_id IS NOT NULL OR (select app.available_in_my_units('catalog.product', id)));
|
|
79
|
+
DROP POLICY IF EXISTS services_available_in ON public.services;
|
|
80
|
+
CREATE POLICY services_available_in ON public.services AS RESTRICTIVE FOR SELECT TO authenticated
|
|
81
|
+
USING (unit_id IS NOT NULL OR (select app.available_in_my_units('catalog.product', id)));
|
|
82
|
+
|
|
83
|
+
-- categories: shared taxonomy with hierarchy — the parent must be a category of
|
|
84
|
+
-- the same tenant. ON DELETE SET NULL (parent_id): only the pointer is cleared,
|
|
85
|
+
-- never tenant_id (a bare SET NULL on a composite FK would null both).
|
|
86
|
+
DO $$
|
|
87
|
+
BEGIN
|
|
88
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.categories'::regclass AND conname = 'categories_parent_tenant_fk') THEN
|
|
89
|
+
ALTER TABLE public.categories
|
|
90
|
+
ADD CONSTRAINT categories_parent_tenant_fk FOREIGN KEY (tenant_id, parent_id)
|
|
91
|
+
REFERENCES public.categories (tenant_id, id) ON DELETE SET NULL (parent_id);
|
|
92
|
+
END IF;
|
|
93
|
+
END $$;
|
|
94
|
+
CREATE INDEX IF NOT EXISTS categories_tenant_parent_idx ON public.categories (tenant_id, parent_id);
|
|
95
|
+
COMMENT ON COLUMN public.categories.kind IS
|
|
96
|
+
'Taxonomy namespace. Catalog categories use the product kind they classify (good | service); other plugins keep their own (menu_category, …).';
|
|
97
|
+
|
|
98
|
+
-- ── 3. write normalisation (legacy kind compat + service_kind pairing) ──
|
|
99
|
+
-- Runs BEFORE the CHECKs. Legacy vocabulary written by the fleet before this
|
|
100
|
+
-- contract: plugin-menu 'menu_item', plugin-inventory 'sale' | 'ingredient' |
|
|
101
|
+
-- 'asset' (+ V1 'intermediate'), courses 'course'. Anything else is left as
|
|
102
|
+
-- written so the CHECK rejects it (an unknown kind must not silently become a
|
|
103
|
+
-- good). Deprecated: once every writer speaks kind+subkind the map goes away.
|
|
104
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_products_normalise()
|
|
105
|
+
RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $$
|
|
106
|
+
BEGIN
|
|
107
|
+
IF NEW.kind IS NULL THEN
|
|
108
|
+
NEW.kind := 'good';
|
|
109
|
+
END IF;
|
|
110
|
+
IF NEW.kind NOT IN ('good', 'service') THEN
|
|
111
|
+
CASE NEW.kind
|
|
112
|
+
WHEN 'menu_item' THEN NEW.subkind := coalesce(NEW.subkind, 'menu_item'); NEW.kind := 'good';
|
|
113
|
+
WHEN 'ingredient' THEN NEW.subkind := coalesce(NEW.subkind, 'ingredient'); NEW.kind := 'good';
|
|
114
|
+
WHEN 'asset' THEN NEW.subkind := coalesce(NEW.subkind, 'asset'); NEW.kind := 'good';
|
|
115
|
+
WHEN 'intermediate' THEN NEW.subkind := coalesce(NEW.subkind, 'intermediate'); NEW.kind := 'good';
|
|
116
|
+
WHEN 'sale' THEN NEW.kind := 'good';
|
|
117
|
+
WHEN 'course' THEN NEW.subkind := coalesce(NEW.subkind, 'course'); NEW.kind := 'service';
|
|
118
|
+
ELSE NULL; -- unknown: the CHECK constraint rejects it
|
|
119
|
+
END CASE;
|
|
120
|
+
END IF;
|
|
121
|
+
IF NEW.kind = 'service' THEN
|
|
122
|
+
NEW.service_kind := coalesce(NEW.service_kind, 'standard');
|
|
123
|
+
ELSE
|
|
124
|
+
NEW.service_kind := NULL;
|
|
125
|
+
END IF;
|
|
126
|
+
RETURN NEW;
|
|
127
|
+
END $$;
|
|
128
|
+
DROP TRIGGER IF EXISTS products_catalog_normalise ON public.products;
|
|
129
|
+
CREATE TRIGGER products_catalog_normalise
|
|
130
|
+
BEFORE INSERT OR UPDATE ON public.products
|
|
131
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_products_normalise();
|
|
132
|
+
|
|
133
|
+
-- ── 4. FOLD services → products (same id) ──────────────────────────────
|
|
134
|
+
INSERT INTO public.products (
|
|
135
|
+
id, tenant_id, category_id, name, description, price, cost, currency, image_url,
|
|
136
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at,
|
|
137
|
+
unit_id, owner_id, kind, duration_minutes
|
|
138
|
+
)
|
|
139
|
+
SELECT
|
|
140
|
+
s.id, s.tenant_id, s.category_id, s.name, s.description, s.price, s.cost, s.currency, s.image_url,
|
|
141
|
+
s.status, s.is_active, s.tags, s.metadata, s.custom_fields, s.created_by, s.created_at, s.updated_at,
|
|
142
|
+
s.unit_id, s.owner_id, 'service', s.duration_minutes
|
|
143
|
+
FROM public.services s
|
|
144
|
+
ON CONFLICT (id) DO NOTHING;
|
|
145
|
+
|
|
146
|
+
-- ── 4b. the rows that were already there ────────────────────────────────
|
|
147
|
+
-- ADD COLUMN IF NOT EXISTS matches a NAME, not a CONTRACT. A pool that already
|
|
148
|
+
-- had a free-text `kind` keeps every value it ever wrote — salon carries 8 NULL,
|
|
149
|
+
-- 4 'sale' and 1 'asset' — and the normaliser above only ever sees a WRITE, so
|
|
150
|
+
-- those rows reach the CHECK untouched and it refuses the whole file.
|
|
151
|
+
--
|
|
152
|
+
-- The map is not restated here. A no-op UPDATE puts each stale row through the
|
|
153
|
+
-- BEFORE trigger, so the legacy vocabulary is translated in exactly ONE place;
|
|
154
|
+
-- a second copy would be the thing that drifts. Only rows that need it are
|
|
155
|
+
-- touched, so a pool already speaking kind+subkind moves nothing.
|
|
156
|
+
UPDATE public.products SET kind = kind
|
|
157
|
+
WHERE kind IS NULL OR kind NOT IN ('good', 'service');
|
|
158
|
+
|
|
159
|
+
-- CHECKs after the fold (existing goods have service_kind NULL; folded services
|
|
160
|
+
-- went through the normaliser and carry 'standard').
|
|
161
|
+
DO $$
|
|
162
|
+
BEGIN
|
|
163
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_kind_check') THEN
|
|
164
|
+
ALTER TABLE public.products ADD CONSTRAINT products_kind_check CHECK (kind IN ('good', 'service'));
|
|
165
|
+
END IF;
|
|
166
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_service_kind_check') THEN
|
|
167
|
+
ALTER TABLE public.products ADD CONSTRAINT products_service_kind_check
|
|
168
|
+
CHECK (service_kind IS NULL OR service_kind IN ('standard', 'evaluation', 'return'));
|
|
169
|
+
END IF;
|
|
170
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_service_kind_pairing_check') THEN
|
|
171
|
+
ALTER TABLE public.products ADD CONSTRAINT products_service_kind_pairing_check
|
|
172
|
+
CHECK ((kind = 'service') = (service_kind IS NOT NULL));
|
|
173
|
+
END IF;
|
|
174
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.products'::regclass AND conname = 'products_duration_check') THEN
|
|
175
|
+
ALTER TABLE public.products ADD CONSTRAINT products_duration_check
|
|
176
|
+
CHECK (duration_minutes IS NULL OR duration_minutes >= 0);
|
|
177
|
+
END IF;
|
|
178
|
+
END $$;
|
|
179
|
+
|
|
180
|
+
-- FKs that pointed at services now point at products (rows carry the same id).
|
|
181
|
+
DO $$
|
|
182
|
+
DECLARE r record;
|
|
183
|
+
BEGIN
|
|
184
|
+
FOR r IN
|
|
185
|
+
SELECT c.conname, c.conrelid::regclass AS tbl
|
|
186
|
+
FROM pg_constraint c
|
|
187
|
+
WHERE c.contype = 'f'
|
|
188
|
+
AND c.confrelid = 'public.services'::regclass
|
|
189
|
+
AND c.conrelid IN ('public.order_items'::regclass, 'public.appointment_items'::regclass)
|
|
190
|
+
LOOP
|
|
191
|
+
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
|
|
192
|
+
END LOOP;
|
|
193
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.order_items'::regclass AND conname = 'order_items_service_id_fkey') THEN
|
|
194
|
+
ALTER TABLE public.order_items
|
|
195
|
+
ADD CONSTRAINT order_items_service_id_fkey FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE SET NULL;
|
|
196
|
+
END IF;
|
|
197
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'public.appointment_items'::regclass AND conname = 'appointment_items_service_id_fkey') THEN
|
|
198
|
+
ALTER TABLE public.appointment_items
|
|
199
|
+
ADD CONSTRAINT appointment_items_service_id_fkey FOREIGN KEY (service_id) REFERENCES public.products(id) ON DELETE SET NULL;
|
|
200
|
+
END IF;
|
|
201
|
+
END $$;
|
|
202
|
+
|
|
203
|
+
-- ── 5. COMPAT mirror: public.services ⇄ public.products (kind='service') ─
|
|
204
|
+
-- The flag `fayz.catalog_sync` (transaction-local GUC) marks a write that the
|
|
205
|
+
-- other side already reflects, so the two triggers never echo each other.
|
|
206
|
+
-- Both are SECURITY DEFINER: the authz decision was taken by RLS on the table
|
|
207
|
+
-- the caller wrote (both carry the same catalog policies); the projection is a
|
|
208
|
+
-- mechanical consequence and must not be filtered by the caller's scope.
|
|
209
|
+
|
|
210
|
+
-- products → services
|
|
211
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_products_mirror()
|
|
212
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
213
|
+
BEGIN
|
|
214
|
+
IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
|
|
215
|
+
RETURN NULL;
|
|
216
|
+
END IF;
|
|
217
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
218
|
+
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.kind <> 'service') THEN
|
|
219
|
+
DELETE FROM public.services s WHERE s.id = OLD.id;
|
|
220
|
+
END IF;
|
|
221
|
+
IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.kind = 'service' THEN
|
|
222
|
+
INSERT INTO public.services (
|
|
223
|
+
id, tenant_id, category_id, name, description, price, cost, currency, duration_minutes, image_url,
|
|
224
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at, unit_id, owner_id
|
|
225
|
+
) VALUES (
|
|
226
|
+
NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.duration_minutes, NEW.image_url,
|
|
227
|
+
NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at, NEW.unit_id, NEW.owner_id
|
|
228
|
+
)
|
|
229
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
230
|
+
tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
|
|
231
|
+
description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
|
|
232
|
+
duration_minutes = EXCLUDED.duration_minutes, image_url = EXCLUDED.image_url, status = EXCLUDED.status,
|
|
233
|
+
is_active = EXCLUDED.is_active, tags = EXCLUDED.tags, metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields,
|
|
234
|
+
created_by = EXCLUDED.created_by, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at,
|
|
235
|
+
unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id;
|
|
236
|
+
END IF;
|
|
237
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
238
|
+
RETURN NULL;
|
|
239
|
+
END $$;
|
|
240
|
+
DROP TRIGGER IF EXISTS products_catalog_mirror ON public.products;
|
|
241
|
+
CREATE TRIGGER products_catalog_mirror
|
|
242
|
+
AFTER INSERT OR UPDATE OR DELETE ON public.products
|
|
243
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_products_mirror();
|
|
244
|
+
|
|
245
|
+
-- services → products (old TS writing `.from('services')`). BEFORE row trigger:
|
|
246
|
+
-- the write is forwarded to products, the flag tells the products trigger not
|
|
247
|
+
-- to project it back, and the statement then lands on services itself so
|
|
248
|
+
-- INSERT … RETURNING keeps working. Named zz_* so it fires after the created_by
|
|
249
|
+
-- / updated_at triggers of the table (BEFORE triggers run in name order).
|
|
250
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_services_forward()
|
|
251
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
252
|
+
BEGIN
|
|
253
|
+
IF coalesce(current_setting('fayz.catalog_sync', true), '') = 'on' THEN
|
|
254
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
255
|
+
END IF;
|
|
256
|
+
IF TG_OP = 'UPDATE' AND NEW.id IS DISTINCT FROM OLD.id THEN
|
|
257
|
+
RAISE EXCEPTION 'services: changing the id of a service is not supported (compat mirror)' USING ERRCODE = '0A000';
|
|
258
|
+
END IF;
|
|
259
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
260
|
+
IF TG_OP = 'DELETE' THEN
|
|
261
|
+
DELETE FROM public.products p WHERE p.id = OLD.id;
|
|
262
|
+
ELSE
|
|
263
|
+
INSERT INTO public.products (
|
|
264
|
+
id, tenant_id, category_id, name, description, price, cost, currency, image_url,
|
|
265
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at,
|
|
266
|
+
unit_id, owner_id, kind, duration_minutes
|
|
267
|
+
) VALUES (
|
|
268
|
+
NEW.id, NEW.tenant_id, NEW.category_id, NEW.name, NEW.description, NEW.price, NEW.cost, NEW.currency, NEW.image_url,
|
|
269
|
+
NEW.status, NEW.is_active, NEW.tags, NEW.metadata, NEW.custom_fields, NEW.created_by, NEW.created_at, NEW.updated_at,
|
|
270
|
+
NEW.unit_id, NEW.owner_id, 'service', NEW.duration_minutes
|
|
271
|
+
)
|
|
272
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
273
|
+
tenant_id = EXCLUDED.tenant_id, category_id = EXCLUDED.category_id, name = EXCLUDED.name,
|
|
274
|
+
description = EXCLUDED.description, price = EXCLUDED.price, cost = EXCLUDED.cost, currency = EXCLUDED.currency,
|
|
275
|
+
image_url = EXCLUDED.image_url, status = EXCLUDED.status, is_active = EXCLUDED.is_active, tags = EXCLUDED.tags,
|
|
276
|
+
metadata = EXCLUDED.metadata, custom_fields = EXCLUDED.custom_fields, created_by = EXCLUDED.created_by,
|
|
277
|
+
updated_at = EXCLUDED.updated_at, unit_id = EXCLUDED.unit_id, owner_id = EXCLUDED.owner_id,
|
|
278
|
+
kind = 'service', duration_minutes = EXCLUDED.duration_minutes;
|
|
279
|
+
END IF;
|
|
280
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
281
|
+
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
|
282
|
+
END $$;
|
|
283
|
+
DROP TRIGGER IF EXISTS zz_services_catalog_forward ON public.services;
|
|
284
|
+
CREATE TRIGGER zz_services_catalog_forward
|
|
285
|
+
BEFORE INSERT OR UPDATE OR DELETE ON public.services
|
|
286
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_services_forward();
|
|
287
|
+
|
|
288
|
+
-- Reconcile the mirror from the source of truth: add missing projections,
|
|
289
|
+
-- drop orphans (a service row whose product is gone or is no longer a
|
|
290
|
+
-- service). Returns the diff; a non-zero diff outside a migration is a bug.
|
|
291
|
+
CREATE OR REPLACE FUNCTION app.catalog_reconcile_services_mirror(OUT added integer, OUT removed integer)
|
|
292
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
293
|
+
BEGIN
|
|
294
|
+
PERFORM set_config('fayz.catalog_sync', 'on', true);
|
|
295
|
+
WITH del AS (
|
|
296
|
+
DELETE FROM public.services s
|
|
297
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = s.id AND p.kind = 'service')
|
|
298
|
+
RETURNING 1
|
|
299
|
+
) SELECT count(*) INTO removed FROM del;
|
|
300
|
+
WITH ins AS (
|
|
301
|
+
INSERT INTO public.services (
|
|
302
|
+
id, tenant_id, category_id, name, description, price, cost, currency, duration_minutes, image_url,
|
|
303
|
+
status, is_active, tags, metadata, custom_fields, created_by, created_at, updated_at, unit_id, owner_id
|
|
304
|
+
)
|
|
305
|
+
SELECT p.id, p.tenant_id, p.category_id, p.name, p.description, p.price, p.cost, p.currency, p.duration_minutes, p.image_url,
|
|
306
|
+
p.status, p.is_active, p.tags, p.metadata, p.custom_fields, p.created_by, p.created_at, p.updated_at, p.unit_id, p.owner_id
|
|
307
|
+
FROM public.products p
|
|
308
|
+
WHERE p.kind = 'service'
|
|
309
|
+
AND NOT EXISTS (SELECT 1 FROM public.services s WHERE s.id = p.id)
|
|
310
|
+
RETURNING 1
|
|
311
|
+
) SELECT count(*) INTO added FROM ins;
|
|
312
|
+
PERFORM set_config('fayz.catalog_sync', '', true);
|
|
313
|
+
END $$;
|
|
314
|
+
REVOKE ALL ON FUNCTION app.catalog_reconcile_services_mirror() FROM public, anon, authenticated;
|
|
315
|
+
GRANT EXECUTE ON FUNCTION app.catalog_reconcile_services_mirror() TO service_role;
|
|
316
|
+
SELECT app.catalog_reconcile_services_mirror();
|
|
317
|
+
|
|
318
|
+
COMMENT ON TABLE public.services IS
|
|
319
|
+
'DEPRECATED compat mirror of public.products WHERE kind = ''service'' (PRD 04 / #139). Reads and writes are forwarded by triggers; the source of truth is products. Phase 1: read-only; Phase 2: dropped. New code reads products.';
|
|
320
|
+
|
|
321
|
+
-- ── 6. global search: goods and services are two sources over one table ─
|
|
322
|
+
UPDATE public.fayz_search_sources
|
|
323
|
+
SET filter_expr = 'kind = ''good'''
|
|
324
|
+
WHERE entity_key = 'product' AND relation = 'public.products';
|
|
325
|
+
UPDATE public.fayz_search_sources
|
|
326
|
+
SET relation = 'public.products', filter_expr = 'kind = ''service'''
|
|
327
|
+
WHERE entity_key = 'service';
|
|
328
|
+
SELECT public.fayz_search_reindex();
|
|
329
|
+
|
|
330
|
+
-- ── grants: unchanged for the three pre-existing tables (004 granted ALL to
|
|
331
|
+
-- authenticated/service_role; RLS narrows). The compat mirror follows products.
|
|
332
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.products, public.categories, public.services TO authenticated;
|
|
333
|
+
GRANT ALL ON public.products, public.categories, public.services TO service_role;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 086_catalog_pricing.sql — the catalog contract, part 2 (PRD 04 R3 / #139):
|
|
3
|
+
-- price tables with validity and scope (unit / channel), price table items,
|
|
4
|
+
-- product variants (the "variations" facet), and catalog_price() — the one
|
|
5
|
+
-- resolver every seller (POS, online, agenda) calls.
|
|
6
|
+
--
|
|
7
|
+
-- The base price stays on the item (products.price). A price table overrides
|
|
8
|
+
-- it for a scope and a validity window; the resolver picks the most specific
|
|
9
|
+
-- applicable table: unit-specific > channel-specific > default > base price.
|
|
10
|
+
--
|
|
11
|
+
-- All three tables are business tables of plugin 'catalog' (scaffold, enforce).
|
|
12
|
+
-- price_table_items inherit (tenant_id, unit_id) from their table so RLS on the
|
|
13
|
+
-- rows agrees with RLS on the table they belong to.
|
|
14
|
+
-- ============================================================================
|
|
15
|
+
|
|
16
|
+
-- ── price_tables ────────────────────────────────────────────────────────
|
|
17
|
+
CREATE TABLE IF NOT EXISTS public.price_tables (
|
|
18
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
19
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
20
|
+
name text NOT NULL,
|
|
21
|
+
description text,
|
|
22
|
+
-- scope: a unit (null = tenant-wide) and/or a sales channel (null = every
|
|
23
|
+
-- channel). Channel vocabulary: 'pos' | 'online' | 'app' — free text so a new
|
|
24
|
+
-- channel is a row, not a migration.
|
|
25
|
+
channel text,
|
|
26
|
+
currency text NOT NULL DEFAULT 'BRL',
|
|
27
|
+
valid_from date,
|
|
28
|
+
valid_to date,
|
|
29
|
+
is_default boolean NOT NULL DEFAULT false,
|
|
30
|
+
priority integer NOT NULL DEFAULT 0,
|
|
31
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
32
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
33
|
+
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
34
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
35
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
36
|
+
CONSTRAINT price_tables_validity_check CHECK (valid_from IS NULL OR valid_to IS NULL OR valid_to >= valid_from)
|
|
37
|
+
);
|
|
38
|
+
-- A pool that already carries a V1-shaped `price_tables` skips the CREATE above
|
|
39
|
+
-- (IF NOT EXISTS matches the NAME, not the shape), and the scope index below
|
|
40
|
+
-- then asks for columns that were never added. Additive, so the existing rows
|
|
41
|
+
-- and their prices are untouched — a cluster's own table converges instead of
|
|
42
|
+
-- failing the apply.
|
|
43
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS name text;
|
|
44
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS channel text;
|
|
45
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS currency text NOT NULL DEFAULT 'BRL';
|
|
46
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS valid_to date;
|
|
47
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
|
48
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS priority integer NOT NULL DEFAULT 0;
|
|
49
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
|
|
50
|
+
ALTER TABLE public.price_tables ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
51
|
+
-- `name` is NOT NULL on a fresh pool; on a converging one the V1 rows have no
|
|
52
|
+
-- name yet, so it is backfilled from what that table did carry rather than
|
|
53
|
+
-- rejecting rows the merchant already owns.
|
|
54
|
+
UPDATE public.price_tables SET name = COALESCE(name, description, 'Tabela ' || left(id::text, 8))
|
|
55
|
+
WHERE name IS NULL;
|
|
56
|
+
ALTER TABLE public.price_tables ALTER COLUMN name SET NOT NULL;
|
|
57
|
+
|
|
58
|
+
SELECT app.scaffold_table('public.price_tables', 'catalog.price_table', 'catalog', false, 'enforce');
|
|
59
|
+
CREATE INDEX IF NOT EXISTS price_tables_tenant_scope_idx
|
|
60
|
+
ON public.price_tables (tenant_id, unit_id, channel) WHERE is_active;
|
|
61
|
+
DROP TRIGGER IF EXISTS price_tables_created_by ON public.price_tables;
|
|
62
|
+
CREATE TRIGGER price_tables_created_by BEFORE INSERT ON public.price_tables
|
|
63
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_created_by();
|
|
64
|
+
COMMENT ON TABLE public.price_tables IS
|
|
65
|
+
'Catalog price tables (PRD 04 R3): a set of item prices valid for a window and a scope (unit_id, channel). V1 price_tables. Resolved by public.catalog_price().';
|
|
66
|
+
|
|
67
|
+
-- ── price_table_items ───────────────────────────────────────────────────
|
|
68
|
+
CREATE TABLE IF NOT EXISTS public.price_table_items (
|
|
69
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
70
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
71
|
+
price_table_id uuid NOT NULL,
|
|
72
|
+
product_id uuid NOT NULL,
|
|
73
|
+
price numeric NOT NULL CHECK (price >= 0),
|
|
74
|
+
min_qty numeric NOT NULL DEFAULT 1 CHECK (min_qty > 0),
|
|
75
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
76
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
77
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
78
|
+
CONSTRAINT price_table_items_table_fk FOREIGN KEY (tenant_id, price_table_id) REFERENCES public.price_tables (tenant_id, id) ON DELETE CASCADE,
|
|
79
|
+
CONSTRAINT price_table_items_product_fk FOREIGN KEY (tenant_id, product_id) REFERENCES public.products (tenant_id, id) ON DELETE CASCADE,
|
|
80
|
+
CONSTRAINT price_table_items_unique UNIQUE (price_table_id, product_id, min_qty)
|
|
81
|
+
);
|
|
82
|
+
SELECT app.scaffold_table('public.price_table_items', 'catalog.price_table_item', 'catalog', false, 'enforce');
|
|
83
|
+
CREATE INDEX IF NOT EXISTS price_table_items_product_idx ON public.price_table_items (tenant_id, product_id);
|
|
84
|
+
-- an item's price is not more visible than the item itself (release rule of 075)
|
|
85
|
+
DROP POLICY IF EXISTS price_table_items_available_in ON public.price_table_items;
|
|
86
|
+
CREATE POLICY price_table_items_available_in ON public.price_table_items AS RESTRICTIVE FOR SELECT TO authenticated
|
|
87
|
+
USING ((select app.available_in_my_units('catalog.product', product_id)));
|
|
88
|
+
COMMENT ON TABLE public.price_table_items IS
|
|
89
|
+
'One price of one product in one price table (optional quantity tier via min_qty). V1 price_table_items. Inherits (tenant_id, unit_id) from the table.';
|
|
90
|
+
|
|
91
|
+
-- Items live in the scope of their table: copy (tenant_id, unit_id) from the
|
|
92
|
+
-- parent on every write so has_unit() answers the same for both.
|
|
93
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_price_table_items_scope()
|
|
94
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
95
|
+
DECLARE v_tenant uuid; v_unit uuid;
|
|
96
|
+
BEGIN
|
|
97
|
+
SELECT t.tenant_id, t.unit_id INTO v_tenant, v_unit FROM public.price_tables t WHERE t.id = NEW.price_table_id;
|
|
98
|
+
IF v_tenant IS NULL THEN
|
|
99
|
+
RAISE EXCEPTION 'price_table_items: price table % does not exist', NEW.price_table_id USING ERRCODE = '23503';
|
|
100
|
+
END IF;
|
|
101
|
+
NEW.tenant_id := v_tenant;
|
|
102
|
+
NEW.unit_id := v_unit;
|
|
103
|
+
RETURN NEW;
|
|
104
|
+
END $$;
|
|
105
|
+
DROP TRIGGER IF EXISTS price_table_items_scope ON public.price_table_items;
|
|
106
|
+
CREATE TRIGGER price_table_items_scope BEFORE INSERT OR UPDATE ON public.price_table_items
|
|
107
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_price_table_items_scope();
|
|
108
|
+
-- …and when a table changes scope, its items follow.
|
|
109
|
+
CREATE OR REPLACE FUNCTION app.trg_catalog_price_tables_rescope_items()
|
|
110
|
+
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
|
|
111
|
+
BEGIN
|
|
112
|
+
IF NEW.unit_id IS DISTINCT FROM OLD.unit_id THEN
|
|
113
|
+
UPDATE public.price_table_items i SET unit_id = NEW.unit_id WHERE i.price_table_id = NEW.id;
|
|
114
|
+
END IF;
|
|
115
|
+
RETURN NULL;
|
|
116
|
+
END $$;
|
|
117
|
+
DROP TRIGGER IF EXISTS price_tables_rescope_items ON public.price_tables;
|
|
118
|
+
CREATE TRIGGER price_tables_rescope_items AFTER UPDATE OF unit_id ON public.price_tables
|
|
119
|
+
FOR EACH ROW EXECUTE FUNCTION app.trg_catalog_price_tables_rescope_items();
|
|
120
|
+
|
|
121
|
+
-- ── product_variants (the "variations" facet) ───────────────────────────
|
|
122
|
+
CREATE TABLE IF NOT EXISTS public.product_variants (
|
|
123
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
124
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
125
|
+
product_id uuid NOT NULL,
|
|
126
|
+
name text NOT NULL,
|
|
127
|
+
sku text,
|
|
128
|
+
-- either an absolute price or a delta on the resolved item price
|
|
129
|
+
price numeric CHECK (price IS NULL OR price >= 0),
|
|
130
|
+
price_delta numeric NOT NULL DEFAULT 0,
|
|
131
|
+
attributes jsonb NOT NULL DEFAULT '{}'::jsonb, -- {"size":"M","color":"blue"}
|
|
132
|
+
sort_order integer NOT NULL DEFAULT 0,
|
|
133
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
134
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
135
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
136
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
137
|
+
CONSTRAINT product_variants_product_fk FOREIGN KEY (tenant_id, product_id) REFERENCES public.products (tenant_id, id) ON DELETE CASCADE
|
|
138
|
+
);
|
|
139
|
+
SELECT app.scaffold_table('public.product_variants', 'catalog.product_variant', 'catalog', false, 'enforce');
|
|
140
|
+
CREATE INDEX IF NOT EXISTS product_variants_product_idx ON public.product_variants (tenant_id, product_id);
|
|
141
|
+
-- a variant is not more visible than its item (release rule of 075)
|
|
142
|
+
DROP POLICY IF EXISTS product_variants_available_in ON public.product_variants;
|
|
143
|
+
CREATE POLICY product_variants_available_in ON public.product_variants AS RESTRICTIVE FOR SELECT TO authenticated
|
|
144
|
+
USING ((select app.available_in_my_units('catalog.product', product_id)));
|
|
145
|
+
CREATE UNIQUE INDEX IF NOT EXISTS product_variants_sku_uq ON public.product_variants (tenant_id, sku) WHERE sku IS NOT NULL;
|
|
146
|
+
COMMENT ON TABLE public.product_variants IS
|
|
147
|
+
'Variations of a catalog item (size, colour, volume…): own sku, absolute price or delta on the resolved price. Catalog facet "variations" (PRD 04 R3).';
|
|
148
|
+
|
|
149
|
+
-- ── catalog_price(product, unit, channel, at, qty) ──────────────────────
|
|
150
|
+
-- The resolver. SECURITY INVOKER on purpose: the caller sees the price tables
|
|
151
|
+
-- her RLS lets her see (a unit-scoped table is invisible outside its unit), so
|
|
152
|
+
-- the answer is always one she is entitled to. Ranking: unit-specific >
|
|
153
|
+
-- channel-specific > higher priority > the default table > highest quantity
|
|
154
|
+
-- tier that applies > most recent validity; then the item's base price.
|
|
155
|
+
CREATE OR REPLACE FUNCTION public.catalog_price(
|
|
156
|
+
p_product uuid,
|
|
157
|
+
p_unit uuid DEFAULT NULL,
|
|
158
|
+
p_channel text DEFAULT NULL,
|
|
159
|
+
p_at date DEFAULT current_date,
|
|
160
|
+
p_qty numeric DEFAULT 1
|
|
161
|
+
)
|
|
162
|
+
RETURNS numeric
|
|
163
|
+
LANGUAGE sql STABLE SET search_path = ''
|
|
164
|
+
AS $$
|
|
165
|
+
SELECT coalesce(
|
|
166
|
+
(SELECT i.price
|
|
167
|
+
FROM public.price_table_items i
|
|
168
|
+
JOIN public.price_tables t ON t.tenant_id = i.tenant_id AND t.id = i.price_table_id
|
|
169
|
+
WHERE i.product_id = p_product
|
|
170
|
+
AND t.is_active
|
|
171
|
+
AND (t.valid_from IS NULL OR t.valid_from <= p_at)
|
|
172
|
+
AND (t.valid_to IS NULL OR t.valid_to >= p_at)
|
|
173
|
+
AND (t.unit_id IS NULL OR t.unit_id = p_unit)
|
|
174
|
+
AND (t.channel IS NULL OR t.channel = p_channel)
|
|
175
|
+
AND i.min_qty <= coalesce(p_qty, 1)
|
|
176
|
+
ORDER BY (t.unit_id IS NOT NULL) DESC,
|
|
177
|
+
(t.channel IS NOT NULL) DESC,
|
|
178
|
+
t.priority DESC,
|
|
179
|
+
t.is_default DESC,
|
|
180
|
+
i.min_qty DESC,
|
|
181
|
+
t.valid_from DESC NULLS LAST,
|
|
182
|
+
t.created_at DESC
|
|
183
|
+
LIMIT 1),
|
|
184
|
+
(SELECT p.price FROM public.products p WHERE p.id = p_product)
|
|
185
|
+
);
|
|
186
|
+
$$;
|
|
187
|
+
REVOKE ALL ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) FROM public, anon;
|
|
188
|
+
GRANT EXECUTE ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) TO authenticated, service_role;
|
|
189
|
+
COMMENT ON FUNCTION public.catalog_price(uuid, uuid, text, date, numeric) IS
|
|
190
|
+
'Resolves the selling price of a catalog item for a unit, a channel, a date and a quantity: the most specific applicable price table (unit > channel > default), else products.price.';
|
|
191
|
+
|
|
192
|
+
-- ── grants ──────────────────────────────────────────────────────────────
|
|
193
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.price_tables, public.price_table_items, public.product_variants TO authenticated;
|
|
194
|
+
GRANT ALL ON public.price_tables, public.price_table_items, public.product_variants TO service_role;
|