@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