@fayz-ai/db 0.25.1 → 0.27.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.
@@ -0,0 +1,187 @@
1
+ -- ============================================================================
2
+ -- 215 — a price table is picked on the sale
3
+ --
4
+ -- A house that sells the same item at three prices (restaurant, resale, end
5
+ -- customer) kept three products per item because nothing let the operator say
6
+ -- "this sale is at the resale price". 086 built price tables for an automatic
7
+ -- resolver (unit > channel > default); this migration adds the other half:
8
+ -- an explicit pick, made on the document, after the customer.
9
+ --
10
+ -- * price_tables.items_only — the table is a catalogue as well as a price
11
+ -- list: a pick offers only what the table lists (default). Off, every
12
+ -- for-sale item is offered, priced by the table where it has a row.
13
+ -- * price_tables.adjustment_percent — the list-wide rule for items the table
14
+ -- does not list (items_only off): base price ± percent. Fixed rows win.
15
+ -- * people.price_table_id — the customer's default table, pre-picked when
16
+ -- the customer is chosen on a document.
17
+ -- * price_table_offer() — what a picked table offers, with its price.
18
+ -- * price_table_add_products() — fill a table with the catalogue at base
19
+ -- price, one atomic action, so a 60-item table is not 60 clicks.
20
+ --
21
+ -- Additive and idempotent. Both functions are SECURITY INVOKER: the caller's
22
+ -- RLS decides which tables, rows and products she can price with.
23
+ -- ============================================================================
24
+
25
+ ALTER TABLE public.price_tables
26
+ ADD COLUMN IF NOT EXISTS items_only boolean NOT NULL DEFAULT true;
27
+ ALTER TABLE public.price_tables
28
+ ADD COLUMN IF NOT EXISTS adjustment_percent numeric;
29
+ DO $$
30
+ BEGIN
31
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
32
+ WHERE conrelid = 'public.price_tables'::regclass
33
+ AND conname = 'price_tables_adjustment_percent_check') THEN
34
+ ALTER TABLE public.price_tables ADD CONSTRAINT price_tables_adjustment_percent_check
35
+ CHECK (adjustment_percent IS NULL OR adjustment_percent > -100);
36
+ END IF;
37
+ END $$;
38
+ COMMENT ON COLUMN public.price_tables.items_only IS
39
+ 'When the table is picked on a sale, only the items it lists are offered (true), or the whole for-sale catalogue with the table price where listed (false).';
40
+ COMMENT ON COLUMN public.price_tables.adjustment_percent IS
41
+ 'List-wide rule for items without a row (items_only = false): base price × (1 + percent/100). NULL = base price. A row''s fixed price always wins.';
42
+
43
+ -- ── the customer's default table ───────────────────────────────────────────
44
+ ALTER TABLE public.people
45
+ ADD COLUMN IF NOT EXISTS price_table_id uuid;
46
+ DO $$
47
+ BEGIN
48
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
49
+ WHERE conrelid = 'public.people'::regclass
50
+ AND conname = 'people_price_table_fk') THEN
51
+ -- SET NULL on the one column: a composite FK with a bare SET NULL would
52
+ -- also null tenant_id and orphan the person.
53
+ ALTER TABLE public.people ADD CONSTRAINT people_price_table_fk
54
+ FOREIGN KEY (tenant_id, price_table_id)
55
+ REFERENCES public.price_tables (tenant_id, id)
56
+ ON DELETE SET NULL (price_table_id);
57
+ END IF;
58
+ END $$;
59
+ CREATE INDEX IF NOT EXISTS people_price_table_idx
60
+ ON public.people (tenant_id, price_table_id) WHERE price_table_id IS NOT NULL;
61
+ COMMENT ON COLUMN public.people.price_table_id IS
62
+ 'Default price table for this customer: pre-picked on a sale once the customer is chosen (215). NULL = the house default or base prices.';
63
+
64
+ -- The masked view of people follows its table (118): a column missing there
65
+ -- reads to a client as "no such field". The N16d gate checks it.
66
+ DO $$
67
+ BEGIN
68
+ IF to_regprocedure('app.masked_view_refresh(regclass,text,text)') IS NOT NULL
69
+ AND EXISTS (SELECT 1 FROM app.masked_views WHERE table_name = 'people') THEN
70
+ PERFORM app.masked_view_refresh('public.people', 'person', 'person');
71
+ END IF;
72
+ END $$;
73
+
74
+ -- ── what a picked table offers ─────────────────────────────────────────────
75
+ CREATE OR REPLACE FUNCTION public.price_table_offer(
76
+ p_table uuid,
77
+ p_query text DEFAULT NULL,
78
+ p_kinds text[] DEFAULT NULL,
79
+ p_limit integer DEFAULT 20,
80
+ p_offset integer DEFAULT 0,
81
+ p_at date DEFAULT current_date,
82
+ p_qty numeric DEFAULT 1,
83
+ p_products uuid[] DEFAULT NULL
84
+ )
85
+ RETURNS TABLE (
86
+ product_id uuid,
87
+ name text,
88
+ sku text,
89
+ kind text,
90
+ description text,
91
+ base_price numeric,
92
+ price numeric,
93
+ listed boolean,
94
+ total_count bigint
95
+ )
96
+ LANGUAGE plpgsql STABLE SECURITY INVOKER SET search_path = ''
97
+ AS $$
98
+ DECLARE
99
+ v_table public.price_tables%ROWTYPE;
100
+ v_q text := nullif(btrim(coalesce(p_query, '')), '');
101
+ BEGIN
102
+ SELECT * INTO v_table FROM public.price_tables t WHERE t.id = p_table;
103
+ IF NOT FOUND THEN
104
+ RAISE EXCEPTION 'price_table_not_found' USING ERRCODE = 'P0002',
105
+ DETAIL = 'The price table does not exist or is not visible to the caller.';
106
+ END IF;
107
+ IF NOT v_table.is_active
108
+ OR (v_table.valid_from IS NOT NULL AND v_table.valid_from > p_at)
109
+ OR (v_table.valid_to IS NOT NULL AND v_table.valid_to < p_at) THEN
110
+ RAISE EXCEPTION 'price_table_not_valid' USING ERRCODE = '22023',
111
+ DETAIL = format('The price table is inactive or outside its validity on %s.', p_at);
112
+ END IF;
113
+
114
+ RETURN QUERY
115
+ WITH listed_rows AS (
116
+ -- The product-wide row with the highest quantity tier that applies.
117
+ SELECT DISTINCT ON (i.product_id) i.product_id, i.price
118
+ FROM public.price_table_items i
119
+ WHERE i.price_table_id = p_table
120
+ AND i.variant_id IS NULL
121
+ AND i.min_qty <= coalesce(p_qty, 1)
122
+ ORDER BY i.product_id, i.min_qty DESC
123
+ ),
124
+ offer AS (
125
+ SELECT p.id, p.name, p.sku, p.kind, p.description, p.price AS base_price,
126
+ r.price AS row_price, (r.product_id IS NOT NULL) AS is_listed
127
+ FROM public.products p
128
+ LEFT JOIN listed_rows r ON r.product_id = p.id
129
+ WHERE p.is_active IS NOT FALSE
130
+ AND (p_kinds IS NULL OR p.kind = ANY (p_kinds))
131
+ AND (p_products IS NULL OR p.id = ANY (p_products))
132
+ AND (r.product_id IS NOT NULL
133
+ OR (NOT v_table.items_only
134
+ AND coalesce(p.metadata ->> 'productType', 'sale') = 'sale'))
135
+ AND (v_q IS NULL OR p.name ILIKE '%' || v_q || '%' OR p.sku ILIKE '%' || v_q || '%')
136
+ )
137
+ SELECT o.id, o.name, o.sku, o.kind, o.description, o.base_price,
138
+ coalesce(o.row_price,
139
+ CASE WHEN v_table.adjustment_percent IS NULL THEN o.base_price
140
+ ELSE round(o.base_price * (1 + v_table.adjustment_percent / 100.0), 2) END),
141
+ o.is_listed,
142
+ count(*) OVER ()
143
+ FROM offer o
144
+ ORDER BY o.name, o.id
145
+ LIMIT greatest(coalesce(p_limit, 20), 0)
146
+ OFFSET greatest(coalesce(p_offset, 0), 0);
147
+ END $$;
148
+ REVOKE ALL ON FUNCTION public.price_table_offer(uuid, text, text[], integer, integer, date, numeric, uuid[]) FROM PUBLIC, anon;
149
+ GRANT EXECUTE ON FUNCTION public.price_table_offer(uuid, text, text[], integer, integer, date, numeric, uuid[]) TO authenticated, service_role;
150
+ COMMENT ON FUNCTION public.price_table_offer(uuid, text, text[], integer, integer, date, numeric, uuid[]) IS
151
+ 'The items a picked price table offers on a sale, each with the table price (fixed row > adjustment_percent > base). items_only limits the offer to listed items. Raises price_table_not_found / price_table_not_valid (215).';
152
+
153
+ -- ── fill a table with the catalogue ────────────────────────────────────────
154
+ CREATE OR REPLACE FUNCTION public.price_table_add_products(
155
+ p_table uuid,
156
+ p_kinds text[] DEFAULT ARRAY['good']
157
+ )
158
+ RETURNS integer
159
+ LANGUAGE plpgsql VOLATILE SECURITY INVOKER SET search_path = ''
160
+ AS $$
161
+ DECLARE
162
+ v_tenant uuid;
163
+ v_added integer;
164
+ BEGIN
165
+ SELECT t.tenant_id INTO v_tenant FROM public.price_tables t WHERE t.id = p_table;
166
+ IF v_tenant IS NULL THEN
167
+ RAISE EXCEPTION 'price_table_not_found' USING ERRCODE = 'P0002',
168
+ DETAIL = 'The price table does not exist or is not visible to the caller.';
169
+ END IF;
170
+
171
+ INSERT INTO public.price_table_items (tenant_id, price_table_id, product_id, price, min_qty)
172
+ SELECT v_tenant, p_table, p.id, coalesce(p.price, 0), 1
173
+ FROM public.products p
174
+ WHERE p.tenant_id = v_tenant
175
+ AND p.is_active IS NOT FALSE
176
+ AND (p_kinds IS NULL OR p.kind = ANY (p_kinds))
177
+ AND coalesce(p.metadata ->> 'productType', 'sale') = 'sale'
178
+ AND NOT EXISTS (SELECT 1 FROM public.price_table_items i
179
+ WHERE i.price_table_id = p_table AND i.product_id = p.id AND i.variant_id IS NULL)
180
+ ON CONFLICT ON CONSTRAINT price_table_items_unique DO NOTHING;
181
+ GET DIAGNOSTICS v_added = ROW_COUNT;
182
+ RETURN v_added;
183
+ END $$;
184
+ REVOKE ALL ON FUNCTION public.price_table_add_products(uuid, text[]) FROM PUBLIC, anon;
185
+ GRANT EXECUTE ON FUNCTION public.price_table_add_products(uuid, text[]) TO authenticated, service_role;
186
+ COMMENT ON FUNCTION public.price_table_add_products(uuid, text[]) IS
187
+ 'Adds every active for-sale product the table does not list yet, at its base price, in one statement. Returns how many rows were added (215).';
@@ -0,0 +1,148 @@
1
+ -- ============================================================================
2
+ -- 216 — a product shows its price in each table
3
+ --
4
+ -- 215 let a sale pick a price table, but the only way to give an item its
5
+ -- resale / restaurant / retail price was to open each table and find the item
6
+ -- in its grid. Operators think the other way round: "this product, its
7
+ -- prices". These two functions back that view on the product page.
8
+ --
9
+ -- * price_table_product_prices(product) — one row per active table with the
10
+ -- product's product-wide price there (the min_qty = 1 row), whether it is
11
+ -- listed, and how many quantity tiers sit above it.
12
+ -- * price_table_set_product_prices(product, prices) — one save, one
13
+ -- transaction: a price puts the product in the table (or reprices it), a
14
+ -- null takes it out (its product-wide rows; variant rows are untouched).
15
+ --
16
+ -- * price_tables.notes / tags — the record rail every registry form draws
17
+ -- (150). The price table form writes both, and without the columns the
18
+ -- screen could not save a table at all ("Could not find the 'notes'
19
+ -- column of 'price_tables'").
20
+ --
21
+ -- Additive and idempotent. Both functions are SECURITY INVOKER: the caller's
22
+ -- RLS decides which tables and rows she can read and write (catalog.read /
23
+ -- create / edit / delete), and a write RLS silently filters is raised, not
24
+ -- swallowed.
25
+ -- ============================================================================
26
+
27
+ ALTER TABLE public.price_tables
28
+ ADD COLUMN IF NOT EXISTS notes text,
29
+ ADD COLUMN IF NOT EXISTS tags text[] DEFAULT '{}'::text[];
30
+ CREATE INDEX IF NOT EXISTS price_tables_tags_gin ON public.price_tables USING gin (tags);
31
+
32
+ CREATE OR REPLACE FUNCTION public.price_table_product_prices(p_product uuid)
33
+ RETURNS TABLE (
34
+ price_table_id uuid,
35
+ name text,
36
+ channel text,
37
+ currency text,
38
+ is_default boolean,
39
+ valid_from date,
40
+ valid_to date,
41
+ base_price numeric,
42
+ price numeric,
43
+ listed boolean,
44
+ tiers integer
45
+ )
46
+ LANGUAGE plpgsql STABLE SECURITY INVOKER SET search_path = ''
47
+ AS $$
48
+ DECLARE
49
+ v_base numeric;
50
+ BEGIN
51
+ SELECT p.price INTO v_base FROM public.products p WHERE p.id = p_product;
52
+ IF NOT FOUND THEN
53
+ RAISE EXCEPTION 'product_not_found' USING ERRCODE = 'P0002',
54
+ DETAIL = 'The product does not exist or is not visible to the caller.';
55
+ END IF;
56
+
57
+ RETURN QUERY
58
+ SELECT t.id, t.name, t.channel, t.currency, t.is_default, t.valid_from, t.valid_to,
59
+ v_base,
60
+ base_row.price,
61
+ (base_row.price IS NOT NULL),
62
+ coalesce(tier.n, 0)::integer
63
+ FROM public.price_tables t
64
+ LEFT JOIN LATERAL (
65
+ SELECT i.price FROM public.price_table_items i
66
+ WHERE i.price_table_id = t.id AND i.product_id = p_product
67
+ AND i.variant_id IS NULL AND i.min_qty = 1
68
+ LIMIT 1
69
+ ) base_row ON true
70
+ LEFT JOIN LATERAL (
71
+ SELECT count(*) AS n FROM public.price_table_items i
72
+ WHERE i.price_table_id = t.id AND i.product_id = p_product
73
+ AND i.variant_id IS NULL AND i.min_qty > 1
74
+ ) tier ON true
75
+ WHERE t.is_active
76
+ ORDER BY t.is_default DESC, t.priority DESC, t.name, t.id;
77
+ END $$;
78
+ REVOKE ALL ON FUNCTION public.price_table_product_prices(uuid) FROM PUBLIC, anon;
79
+ GRANT EXECUTE ON FUNCTION public.price_table_product_prices(uuid) TO authenticated, service_role;
80
+ COMMENT ON FUNCTION public.price_table_product_prices(uuid) IS
81
+ 'One row per active price table with this product''s product-wide price there (min_qty = 1), listed or not, and its quantity-tier count. Raises product_not_found (216).';
82
+
83
+ CREATE OR REPLACE FUNCTION public.price_table_set_product_prices(
84
+ p_product uuid,
85
+ p_prices jsonb
86
+ )
87
+ RETURNS integer
88
+ LANGUAGE plpgsql VOLATILE SECURITY INVOKER SET search_path = ''
89
+ AS $$
90
+ DECLARE
91
+ v_tenant uuid;
92
+ v_entry jsonb;
93
+ v_table uuid;
94
+ v_price numeric;
95
+ v_changed integer := 0;
96
+ v_n integer;
97
+ BEGIN
98
+ IF p_prices IS NULL OR jsonb_typeof(p_prices) <> 'array' THEN
99
+ RAISE EXCEPTION 'prices_not_an_array' USING ERRCODE = '22023',
100
+ DETAIL = 'p_prices is a JSON array of {"price_table_id": uuid, "price": number|null}.';
101
+ END IF;
102
+
103
+ SELECT p.tenant_id INTO v_tenant FROM public.products p WHERE p.id = p_product;
104
+ IF v_tenant IS NULL THEN
105
+ RAISE EXCEPTION 'product_not_found' USING ERRCODE = 'P0002',
106
+ DETAIL = 'The product does not exist or is not visible to the caller.';
107
+ END IF;
108
+
109
+ FOR v_entry IN SELECT e FROM jsonb_array_elements(p_prices) e LOOP
110
+ v_table := nullif(v_entry ->> 'price_table_id', '')::uuid;
111
+ IF v_table IS NULL OR NOT EXISTS (SELECT 1 FROM public.price_tables t
112
+ WHERE t.id = v_table AND t.tenant_id = v_tenant) THEN
113
+ RAISE EXCEPTION 'price_table_not_found' USING ERRCODE = 'P0002',
114
+ DETAIL = format('Price table %s does not exist or is not visible to the caller.', v_table);
115
+ END IF;
116
+ v_price := (v_entry ->> 'price')::numeric;
117
+
118
+ IF v_price IS NULL THEN
119
+ DELETE FROM public.price_table_items i
120
+ WHERE i.price_table_id = v_table AND i.product_id = p_product AND i.variant_id IS NULL;
121
+ GET DIAGNOSTICS v_n = ROW_COUNT;
122
+ -- RLS turns a forbidden DELETE into "0 rows"; say so instead of reporting success.
123
+ IF EXISTS (SELECT 1 FROM public.price_table_items i
124
+ WHERE i.price_table_id = v_table AND i.product_id = p_product AND i.variant_id IS NULL) THEN
125
+ RAISE EXCEPTION 'price_table_item_not_removable' USING ERRCODE = '42501',
126
+ DETAIL = 'The caller may not remove items from this price table.';
127
+ END IF;
128
+ ELSE
129
+ IF v_price < 0 THEN
130
+ RAISE EXCEPTION 'price_negative' USING ERRCODE = '22023',
131
+ DETAIL = format('A table price cannot be negative (%s).', v_price);
132
+ END IF;
133
+ INSERT INTO public.price_table_items (tenant_id, price_table_id, product_id, price, min_qty)
134
+ VALUES (v_tenant, v_table, p_product, v_price, 1)
135
+ ON CONFLICT ON CONSTRAINT price_table_items_unique
136
+ DO UPDATE SET price = EXCLUDED.price, updated_at = now()
137
+ WHERE public.price_table_items.price IS DISTINCT FROM EXCLUDED.price;
138
+ GET DIAGNOSTICS v_n = ROW_COUNT;
139
+ END IF;
140
+ v_changed := v_changed + least(v_n, 1);
141
+ END LOOP;
142
+
143
+ RETURN v_changed;
144
+ END $$;
145
+ REVOKE ALL ON FUNCTION public.price_table_set_product_prices(uuid, jsonb) FROM PUBLIC, anon;
146
+ GRANT EXECUTE ON FUNCTION public.price_table_set_product_prices(uuid, jsonb) TO authenticated, service_role;
147
+ COMMENT ON FUNCTION public.price_table_set_product_prices(uuid, jsonb) IS
148
+ 'Saves one product''s price in several tables at once: [{"price_table_id", "price"}], a price upserts the min_qty = 1 row, null removes the product-wide rows. Returns how many tables changed. Raises product_not_found / price_table_not_found / price_negative / price_table_item_not_removable (216).';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.25.1",
6
+ "version": "0.27.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,