@fayz-ai/plugin-inventory 0.12.2 → 0.12.3

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.
@@ -1,6 +1,7 @@
1
1
  export declare const MIGRATION_000_BASELINE = "-- ============================================================================\n-- plugins/plugin-inventory/src/migrations/000_baseline.sql \u2014 what installing this provisions, as one file.\n--\n-- GENERATED by scripts/emit-unit-baselines.mjs (#338). Do not edit by hand:\n-- change the schema, regenerate, and let scripts/check-chain-equivalence.mjs\n-- prove the result still matches.\n--\n-- This replaced 28 migration files. They are kept, unapplied, in\n-- migrations.archive/ \u2014 a year of decisions is worth reading even when it is no\n-- longer worth replaying.\n--\n-- Object order is fixed rather than dependency-sorted: schemas, types,\n-- sequences, tables, defaults, functions, constraints, views, indexes, foreign\n-- keys, triggers, row security, policies, grants. Dependencies BETWEEN units\n-- are carried by the order packages/db/chain.json declares.\n-- ============================================================================\n\n-- A LANGUAGE sql function binds its body at CREATE time, and 570 functions\n-- sorted by name are not sorted by who calls whom. This is what lets them load\n-- in any order; every body is still compiled on first call, so a genuinely\n-- broken reference surfaces then rather than never. Session-scoped.\nSET check_function_bodies = false;\n\n\n\n-- \u2500\u2500 default privileges: reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Cleared BEFORE anything is created, so every object below gets the ACL the\n-- dump describes rather than the image's defaults on top of it. Restored at\n-- the end of this file.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role, fayz_connector$stmt$;\nEND $dp$;\n\n\n-- \u2500\u2500 table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE TABLE public.plg_inventory_count_items (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n session_id uuid NOT NULL,\n product_id uuid NOT NULL,\n system_quantity numeric(14,4) DEFAULT 0 NOT NULL,\n counted_quantity numeric(14,4),\n unit_cost numeric(14,2) DEFAULT 0 NOT NULL,\n notes text,\n counted_at timestamp with time zone,\n counted_by uuid,\n movement_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_count_items FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_count_sessions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n reference text,\n status text DEFAULT 'open'::text NOT NULL,\n stock_location_id uuid NOT NULL,\n category_id uuid,\n blind boolean DEFAULT true NOT NULL,\n notes text,\n opened_at timestamp with time zone DEFAULT now() NOT NULL,\n opened_by uuid,\n closed_at timestamp with time zone,\n closed_by uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_count_sessions_status_check CHECK ((status = ANY (ARRAY['open'::text, 'counting'::text, 'closed'::text, 'cancelled'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_fulfillment_items (\n item_id uuid NOT NULL,\n parent_kind text DEFAULT 'fulfillment'::text NOT NULL,\n tenant_id uuid NOT NULL,\n unit_id uuid,\n batch_number text,\n expiration_date date,\n delivered_by uuid,\n stock_location_id uuid,\n movement_id uuid,\n owner_id uuid,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n CONSTRAINT plg_inventory_fulfillment_items_parent_kind_check CHECK ((parent_kind = 'fulfillment'::text))\n);\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_measurement_units (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n abbreviation text NOT NULL,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n code text,\n kind text DEFAULT 'unit'::text NOT NULL,\n is_base boolean DEFAULT false NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n CONSTRAINT plg_inventory_measurement_units_kind_check CHECK ((kind = ANY (ARRAY['unit'::text, 'mass'::text, 'volume'::text, 'length'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_operations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n kind text NOT NULL,\n idempotency_key text NOT NULL,\n status text DEFAULT 'pending'::text NOT NULL,\n actor_id uuid,\n request jsonb DEFAULT '{}'::jsonb NOT NULL,\n result jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_operations_kind_check CHECK ((kind = ANY (ARRAY['receive'::text, 'transfer'::text, 'adjust'::text, 'reserve'::text, 'confirm'::text, 'reverse'::text]))),\n CONSTRAINT plg_inventory_operations_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'done'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_operations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_product_categories (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n parent_id uuid,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_product_categories FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_product_details (\n product_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n category_id uuid,\n measurement_unit_id uuid,\n purchase_unit_id uuid,\n conversion_factor numeric(14,4) DEFAULT 1 NOT NULL,\n supplier_id uuid,\n default_location_id uuid,\n purpose text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE public.plg_inventory_product_settings (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n min_quantity numeric(16,4) DEFAULT 0 NOT NULL,\n max_quantity numeric(16,4),\n measurement_unit_id uuid,\n default_location_id uuid,\n track_batches boolean DEFAULT false NOT NULL,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_product_settings_max_quantity_check CHECK (((max_quantity IS NULL) OR (max_quantity >= (0)::numeric))),\n CONSTRAINT plg_inventory_product_settings_min_quantity_check CHECK ((min_quantity >= (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_product_settings FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_recipe_groups (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n recipe_id uuid NOT NULL,\n name text NOT NULL,\n preparation_notes text,\n display_order integer DEFAULT 0 NOT NULL,\n estimated_minutes integer,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL\n);\n\nCREATE TABLE public.plg_inventory_recipe_ingredients (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n recipe_id uuid NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n unit_id uuid,\n display_order integer DEFAULT 0,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n group_id uuid\n);\n\nCREATE TABLE public.plg_inventory_recipes (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n description text,\n product_id uuid,\n yield_quantity numeric(14,4) DEFAULT 1,\n yield_unit_id uuid,\n preparation_time_minutes integer,\n instructions text,\n is_active boolean DEFAULT true NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n metadata jsonb,\n unit_id uuid,\n owner_id uuid\n);\n\nALTER TABLE ONLY public.plg_inventory_recipes FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_reservations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n idempotency_key text NOT NULL,\n line_no integer DEFAULT 1 NOT NULL,\n source_type text NOT NULL,\n source_id uuid NOT NULL,\n product_id uuid NOT NULL,\n stock_location_id uuid NOT NULL,\n batch_number text,\n expiration_date date,\n quantity numeric(16,4) NOT NULL,\n measurement_unit_id uuid,\n status text DEFAULT 'reserved'::text NOT NULL,\n movement_id uuid,\n reversal_movement_id uuid,\n reason text,\n confirmed_at timestamp with time zone,\n confirmed_by uuid,\n reversed_at timestamp with time zone,\n reversed_by uuid,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_inventory_reservations_quantity_check CHECK ((quantity > (0)::numeric)),\n CONSTRAINT plg_inventory_reservations_status_check CHECK ((status = ANY (ARRAY['reserved'::text, 'confirmed'::text, 'reversed'::text])))\n);\n\nALTER TABLE ONLY public.plg_inventory_reservations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_locations (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n name text NOT NULL,\n description text,\n is_active boolean DEFAULT true NOT NULL,\n unit_id uuid NOT NULL,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n owner_id uuid,\n code text,\n metadata jsonb DEFAULT '{}'::jsonb NOT NULL\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_movements (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n kind text NOT NULL,\n unit_cost numeric(16,4) DEFAULT 0 NOT NULL,\n total_cost numeric(16,4) DEFAULT 0 NOT NULL,\n source_location_id uuid NOT NULL,\n destination_location_id uuid,\n supplier_id uuid,\n document_number text,\n reason text,\n notes text,\n movement_date date DEFAULT CURRENT_DATE NOT NULL,\n user_id uuid,\n batch_number text,\n expiration_date date,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n stock_location_id uuid GENERATED ALWAYS AS (source_location_id) STORED,\n movement_type text GENERATED ALWAYS AS (kind) STORED,\n document_type text,\n reverses_movement_id uuid,\n idempotency_key text,\n line_no integer DEFAULT 1 NOT NULL,\n operation_id uuid,\n source_item_type text,\n source_item_id uuid,\n measurement_unit_id uuid,\n CONSTRAINT plg_inventory_stock_movements_cost_check CHECK (((unit_cost >= (0)::numeric) AND (total_cost >= (0)::numeric))),\n CONSTRAINT plg_inventory_stock_movements_kind_check CHECK ((kind = ANY (ARRAY['in'::text, 'out'::text, 'transfer'::text, 'adjust'::text, 'reverse'::text]))),\n CONSTRAINT plg_inventory_stock_movements_quantity_check CHECK ((quantity <> (0)::numeric)),\n CONSTRAINT plg_inventory_stock_movements_reverse_check CHECK (((kind = 'reverse'::text) = (reverses_movement_id IS NOT NULL))),\n CONSTRAINT plg_inventory_stock_movements_sign_check CHECK ((((kind = 'in'::text) AND (quantity > (0)::numeric)) OR ((kind = 'out'::text) AND (quantity < (0)::numeric)) OR ((kind = 'transfer'::text) AND (quantity < (0)::numeric)) OR (kind = ANY (ARRAY['adjust'::text, 'reverse'::text])))),\n CONSTRAINT plg_inventory_stock_movements_transfer_check CHECK ((((kind = 'transfer'::text) AND (destination_location_id IS NOT NULL) AND (destination_location_id <> source_location_id)) OR ((kind = ANY (ARRAY['in'::text, 'out'::text, 'adjust'::text])) AND (destination_location_id IS NULL)) OR (kind = 'reverse'::text)))\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_stock_positions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n product_id uuid NOT NULL,\n quantity numeric(14,4) NOT NULL,\n unit_cost numeric(16,4) DEFAULT 0 NOT NULL,\n stock_location_id uuid NOT NULL,\n batch_number text,\n expiration_date date,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n measurement_unit_id uuid,\n unit_type text DEFAULT 'base'::text NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid NOT NULL,\n owner_id uuid,\n CONSTRAINT plg_inventory_stock_positions_quantity_check CHECK ((quantity >= (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions FORCE ROW LEVEL SECURITY;\n\nCREATE TABLE public.plg_inventory_unit_conversions (\n id uuid DEFAULT gen_random_uuid() NOT NULL,\n tenant_id uuid NOT NULL,\n from_unit_id uuid NOT NULL,\n to_unit_id uuid NOT NULL,\n factor numeric(20,8) NOT NULL,\n product_id uuid,\n notes text,\n created_at timestamp with time zone DEFAULT now() NOT NULL,\n updated_at timestamp with time zone DEFAULT now() NOT NULL,\n unit_id uuid,\n owner_id uuid,\n CONSTRAINT plg_inventory_unit_conversions_check CHECK ((from_unit_id <> to_unit_id)),\n CONSTRAINT plg_inventory_unit_conversions_factor_check CHECK ((factor > (0)::numeric))\n);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions FORCE ROW LEVEL SECURITY;\n\n\n-- \u2500\u2500 function \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE FUNCTION app.inventory_apply_to_position(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date, p_effect numeric, p_unit_cost numeric, p_measurement_unit uuid, OUT o_quantity numeric, OUT o_unit_cost numeric) RETURNS record\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_unit uuid;\n v_pos record;\n v_new_qty numeric;\n v_new_cost numeric;\nBEGIN\n SELECT l.unit_id INTO v_unit FROM public.plg_inventory_stock_locations l WHERE l.id = p_location AND l.tenant_id = p_tenant;\n SELECT p.id, p.quantity, p.unit_cost INTO v_pos\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)\n FOR UPDATE;\n IF v_pos.id IS NULL THEN\n IF p_effect < 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock (no position for product % at location %)', p_product, p_location USING ERRCODE = '23514';\n END IF;\n INSERT INTO public.plg_inventory_stock_positions\n (tenant_id, unit_id, product_id, stock_location_id, batch_number, expiration_date, quantity, unit_cost, measurement_unit_id)\n VALUES (p_tenant, v_unit, p_product, p_location, p_batch, p_expiry, p_effect, coalesce(p_unit_cost, 0), p_measurement_unit)\n RETURNING plg_inventory_stock_positions.quantity, plg_inventory_stock_positions.unit_cost INTO o_quantity, o_unit_cost;\n RETURN;\n END IF;\n v_new_qty := v_pos.quantity + p_effect;\n IF v_new_qty < 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock (product % at location %: % on hand, % requested)', p_product, p_location, v_pos.quantity, -p_effect USING ERRCODE = '23514';\n END IF;\n IF p_effect > 0 AND v_new_qty > 0 THEN\n v_new_cost := (v_pos.quantity * v_pos.unit_cost + p_effect * coalesce(p_unit_cost, v_pos.unit_cost)) / v_new_qty;\n ELSE\n v_new_cost := v_pos.unit_cost;\n END IF;\n UPDATE public.plg_inventory_stock_positions p\n SET quantity = v_new_qty, unit_cost = round(v_new_cost, 4), unit_id = v_unit,\n measurement_unit_id = coalesce(p.measurement_unit_id, p_measurement_unit)\n WHERE p.id = v_pos.id\n RETURNING p.quantity, p.unit_cost INTO o_quantity, o_unit_cost;\nEND $$;\n\nCREATE FUNCTION app.inventory_authorize_location(p_tenant uuid, p_location uuid, p_perm text) RETURNS uuid\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_loc record;\nBEGIN\n IF p_location IS NULL THEN\n RAISE EXCEPTION 'inventory: a stock location is required' USING ERRCODE = '22023';\n END IF;\n SELECT id, unit_id, is_active INTO v_loc FROM public.plg_inventory_stock_locations WHERE id = p_location AND tenant_id = p_tenant;\n -- unknown, another tenant's, or outside the caller's units: one answer, no leak\n IF v_loc.id IS NULL OR NOT app.has_unit(v_loc.unit_id) THEN\n RAISE EXCEPTION 'inventory: no access to stock location %', p_location USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission(p_perm, v_loc.unit_id) THEN\n RAISE EXCEPTION 'inventory: % required at the unit of stock location %', p_perm, p_location USING ERRCODE = '42501';\n END IF;\n RETURN v_loc.unit_id;\nEND $$;\n\nCREATE FUNCTION app.inventory_available(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) RETURNS numeric\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT coalesce((SELECT p.quantity FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)), 0)\n - coalesce((SELECT sum(r.quantity) FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.product_id = p_product AND r.stock_location_id = p_location\n AND coalesce(r.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(r.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)\n AND r.status = 'reserved'), 0);\n$$;\n\nCREATE FUNCTION app.inventory_begin_operation(p_tenant uuid, p_kind text, p_key text, p_request jsonb, OUT op_id uuid, OUT existing jsonb) RETURNS record\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n -- from here to inventory_finish_operation the ledger tables accept writes\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n INSERT INTO public.plg_inventory_operations (tenant_id, kind, idempotency_key, actor_id, request)\n VALUES (p_tenant, p_kind, p_key, auth.uid(), coalesce(p_request, '{}'::jsonb))\n ON CONFLICT (tenant_id, kind, idempotency_key) DO NOTHING\n RETURNING id INTO op_id;\n IF op_id IS NULL THEN\n SELECT result INTO existing FROM public.plg_inventory_operations\n WHERE tenant_id = p_tenant AND kind = p_kind AND idempotency_key = p_key;\n IF existing IS NULL THEN\n -- a visible row without a result can only be a call that failed after\n -- committing nothing \u2014 impossible in one transaction \u2014 or a concurrent\n -- call still in flight after the wait; refuse rather than double-apply\n RAISE EXCEPTION 'inventory: operation % is still in progress', p_key USING ERRCODE = '55P03';\n END IF;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n END IF;\nEND $$;\n\nCREATE FUNCTION app.inventory_check_lines(p_lines jsonb) RETURNS void\n LANGUAGE plpgsql IMMUTABLE\n AS $$\nBEGIN\n IF p_lines IS NULL OR jsonb_typeof(p_lines) <> 'array' OR jsonb_array_length(p_lines) = 0 THEN\n RAISE EXCEPTION 'inventory: lines must be a non-empty JSON array' USING ERRCODE = '22023';\n END IF;\nEND $$;\n\nCREATE FUNCTION app.inventory_consume_order_item(p_tenant uuid, p_order uuid, p_order_item uuid, p_unit uuid, p_product uuid, p_quantity numeric, p_actor uuid DEFAULT NULL::uuid) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_key text := 'order_item:' || p_order_item::text;\n v_op record;\n v_reservation record;\n v_recipe record;\n v_requirement jsonb;\n v_requirements jsonb := '[]'::jsonb;\n v_parsed record;\n v_position record;\n v_location uuid;\n v_remaining numeric;\n v_available numeric;\n v_take numeric;\n v_reservation_cost numeric;\n v_movement uuid;\n v_line_no integer := 0;\n v_movements jsonb := '[]'::jsonb;\n v_mode text := 'direct_product';\nBEGIN\n IF p_tenant IS NULL OR p_order IS NULL OR p_order_item IS NULL OR p_product IS NULL\n OR coalesce(p_quantity, 0) <= 0 THEN\n RAISE EXCEPTION 'inventory: invalid order item consumption request' USING ERRCODE = '22023';\n END IF;\n\n -- Compatibility with the former browser bridge. If it already reserved the\n -- recipe lines, finish those exact lines instead of resolving the recipe a\n -- second time. The confirm operation key is shared by both implementations.\n IF EXISTS (\n SELECT 1 FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.idempotency_key = v_key\n ) THEN\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n p_tenant, 'confirm', v_key,\n jsonb_build_object('source', 'order.completed', 'order_id', p_order, 'actor_id', p_actor)\n );\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR v_reservation IN\n SELECT r.* FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = p_tenant AND r.idempotency_key = v_key\n ORDER BY r.line_no FOR UPDATE\n LOOP\n IF v_reservation.status = 'reserved' THEN\n SELECT p.unit_cost INTO v_reservation_cost\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant\n AND p.product_id = v_reservation.product_id\n AND p.stock_location_id = v_reservation.stock_location_id\n AND coalesce(p.batch_number, '') = coalesce(v_reservation.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_reservation.expiration_date, 'infinity'::date);\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n document_number, source_item_type, source_item_id, idempotency_key,\n line_no, operation_id, user_id, metadata)\n VALUES\n (p_tenant, v_reservation.product_id, 'out', -v_reservation.quantity,\n coalesce(v_reservation_cost, 0), v_reservation.stock_location_id,\n v_reservation.batch_number, v_reservation.expiration_date,\n v_reservation.measurement_unit_id, 'consumption', p_order::text,\n 'order_item', p_order_item, v_key, v_reservation.line_no, v_op.op_id,\n p_actor, coalesce(v_reservation.metadata, '{}'::jsonb)\n || jsonb_build_object('source', 'order.completed', 'reservation_id', v_reservation.id))\n RETURNING id INTO v_movement;\n\n UPDATE public.plg_inventory_reservations r\n SET status = 'confirmed', confirmed_at = now(), confirmed_by = p_actor,\n movement_id = v_movement\n WHERE r.id = v_reservation.id AND r.status = 'reserved';\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n END IF;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n p_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm',\n 'idempotency_key', v_key, 'source_type', 'order_item',\n 'source_id', p_order_item, 'status', 'confirmed',\n 'mode', 'legacy_reservation', 'movements', v_movements),\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'actor_id', p_actor)\n );\n END IF;\n\n SELECT r.id, r.yield_quantity INTO v_recipe\n FROM public.plg_inventory_recipes r\n WHERE r.tenant_id = p_tenant\n AND r.product_id = p_product\n AND r.is_active\n ORDER BY r.updated_at DESC, r.id\n LIMIT 1;\n\n IF v_recipe.id IS NOT NULL THEN\n IF coalesce(v_recipe.yield_quantity, 0) <= 0 THEN\n RAISE EXCEPTION 'inventory: recipe % has an invalid yield quantity', v_recipe.id\n USING ERRCODE = '22023';\n END IF;\n\n SELECT coalesce(jsonb_agg(jsonb_build_object(\n 'product_id', x.product_id,\n 'quantity', round(x.quantity * p_quantity / v_recipe.yield_quantity, 4),\n 'measurement_unit_id', x.unit_id,\n 'recipe_id', v_recipe.id\n ) ORDER BY x.first_order), '[]'::jsonb)\n INTO v_requirements\n FROM (\n SELECT i.product_id, i.unit_id, sum(i.quantity) AS quantity,\n min(i.display_order) AS first_order\n FROM public.plg_inventory_recipe_ingredients i\n WHERE i.tenant_id = p_tenant AND i.recipe_id = v_recipe.id\n GROUP BY i.product_id, i.unit_id\n ) x;\n\n IF jsonb_array_length(v_requirements) = 0 THEN\n RAISE EXCEPTION 'inventory: recipe % has no ingredients', v_recipe.id\n USING ERRCODE = '22023';\n END IF;\n v_mode := 'recipe';\n ELSE\n v_requirements := jsonb_build_array(jsonb_build_object(\n 'product_id', p_product,\n 'quantity', round(p_quantity, 4),\n 'measurement_unit_id', NULL\n ));\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n p_tenant, 'confirm', v_key,\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'product_id', p_product,\n 'quantity', p_quantity, 'mode', v_mode, 'actor_id', p_actor)\n );\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n FOR v_requirement IN SELECT value FROM jsonb_array_elements(v_requirements)\n LOOP\n SELECT * INTO v_parsed FROM app.inventory_parse_line(\n p_tenant,\n jsonb_strip_nulls(jsonb_build_object(\n 'product_id', v_requirement ->> 'product_id',\n 'quantity', v_requirement ->> 'quantity',\n 'measurement_unit_id', v_requirement ->> 'measurement_unit_id'\n )),\n 'positive'\n );\n\n v_remaining := round(v_parsed.quantity, 4);\n IF v_remaining <= 0 THEN\n RAISE EXCEPTION 'inventory: order item % produced a zero stock requirement', p_order_item\n USING ERRCODE = '22023';\n END IF;\n\n v_location := NULL;\n SELECT candidate.location_id INTO v_location\n FROM (\n SELECT s.default_location_id AS location_id,\n CASE WHEN p_unit IS NOT NULL AND s.unit_id = p_unit THEN 1 ELSE 2 END AS priority\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = p_tenant\n AND s.product_id = v_parsed.product_id\n AND s.default_location_id IS NOT NULL\n AND (s.unit_id IS NULL OR s.unit_id = p_unit)\n UNION ALL\n SELECT d.default_location_id, 3\n FROM public.plg_inventory_product_details d\n WHERE d.tenant_id = p_tenant\n AND d.product_id = v_parsed.product_id\n AND d.default_location_id IS NOT NULL\n UNION ALL\n SELECT l.id, 4\n FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = p_tenant AND l.is_active\n ) candidate\n JOIN public.plg_inventory_stock_locations l\n ON l.id = candidate.location_id\n AND l.tenant_id = p_tenant\n AND l.is_active\n WHERE p_unit IS NULL OR l.unit_id = p_unit\n ORDER BY candidate.priority, l.created_at, l.id\n LIMIT 1;\n\n IF v_location IS NULL THEN\n RAISE EXCEPTION 'inventory: product % has no active stock location for order unit %',\n v_parsed.product_id, p_unit USING ERRCODE = '22023';\n END IF;\n\n FOR v_position IN\n SELECT p.*\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant\n AND p.product_id = v_parsed.product_id\n AND p.stock_location_id = v_location\n AND p.quantity > 0\n ORDER BY p.expiration_date NULLS LAST, p.created_at, p.id\n FOR UPDATE\n LOOP\n v_available := app.inventory_available(\n p_tenant, v_position.product_id, v_position.stock_location_id,\n v_position.batch_number, v_position.expiration_date\n );\n CONTINUE WHEN v_available <= 0;\n v_take := least(v_remaining, v_available);\n v_line_no := v_line_no + 1;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n document_number, source_item_type, source_item_id, idempotency_key,\n line_no, operation_id, user_id, metadata)\n VALUES\n (p_tenant, v_parsed.product_id, 'out', -v_take,\n coalesce(v_position.unit_cost, 0), v_location,\n v_position.batch_number, v_position.expiration_date,\n coalesce(v_position.measurement_unit_id, v_parsed.measurement_unit_id),\n 'consumption', p_order::text, 'order_item', p_order_item, v_key,\n v_line_no, v_op.op_id, p_actor,\n jsonb_strip_nulls(jsonb_build_object(\n 'source', 'order.completed',\n 'mode', v_mode,\n 'recipe_id', v_requirement ->> 'recipe_id',\n 'declared', v_parsed.declared\n )))\n RETURNING id INTO v_movement;\n\n v_movements := v_movements || app.inventory_movement_json(v_movement);\n v_remaining := round(v_remaining - v_take, 4);\n EXIT WHEN v_remaining <= 0;\n END LOOP;\n\n IF v_remaining > 0 THEN\n RAISE EXCEPTION 'inventory: insufficient stock for product % at location % (% missing)',\n v_parsed.product_id, v_location, v_remaining USING ERRCODE = '23514';\n END IF;\n END LOOP;\n\n RETURN app.inventory_finish_operation(\n p_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm',\n 'idempotency_key', v_key, 'source_type', 'order_item',\n 'source_id', p_order_item, 'status', 'confirmed', 'mode', v_mode,\n 'recipe_id', v_recipe.id, 'movements', v_movements),\n jsonb_build_object('source', 'order.completed', 'order_id', p_order,\n 'order_item_id', p_order_item, 'actor_id', p_actor,\n 'movement_count', v_line_no, 'mode', v_mode)\n );\nEND $$;\n\nCREATE FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) RETURNS uuid\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT v.id\n FROM public.product_variants v\n WHERE v.tenant_id = p_tenant\n AND v.product_id = p_product\n AND v.is_active\n ORDER BY (v.is_default IS TRUE) DESC, v.sort_order, v.created_at\n LIMIT 1\n$$;\n\nCREATE FUNCTION app.inventory_finish_operation(p_tenant uuid, p_op uuid, p_kind text, p_key text, p_result jsonb, p_audit jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n UPDATE public.plg_inventory_operations SET status = 'done', result = p_result WHERE id = p_op;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant, auth.uid(), 'inventory.' || p_kind, 'inventory.operation', p_op::text,\n jsonb_build_object('idempotency_key', p_key) || coalesce(p_audit, '{}'::jsonb));\n RETURN p_result;\nEND $$;\n\nCREATE FUNCTION app.inventory_internal_only() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nBEGIN\n IF coalesce(current_setting('fayz.inventory_internal', true), 'off') <> 'on' THEN\n RAISE EXCEPTION 'inventory: % is written only through the inventory RPCs (inventory_receive / _transfer / _adjust / _reserve / _confirm / _reverse)', TG_TABLE_NAME\n USING ERRCODE = '55000';\n END IF;\n IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_locations_before_update() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.unit_id IS DISTINCT FROM OLD.unit_id\n AND EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = OLD.tenant_id\n AND (m.source_location_id = OLD.id OR m.destination_location_id = OLD.id)) THEN\n RAISE EXCEPTION 'inventory: a stock location with movements cannot change unit (deactivate it and create a new one)'\n USING ERRCODE = '55000';\n END IF;\n IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN\n RAISE EXCEPTION 'inventory: a stock location cannot change tenant' USING ERRCODE = '55000';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_movement_json(p_id uuid) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT jsonb_build_object(\n 'id', m.id, 'line_no', m.line_no, 'kind', m.kind, 'product_id', m.product_id, 'quantity', m.quantity,\n 'unit_cost', m.unit_cost, 'total_cost', m.total_cost,\n 'source_location_id', m.source_location_id, 'destination_location_id', m.destination_location_id,\n 'batch_number', m.batch_number, 'expiration_date', m.expiration_date,\n 'measurement_unit_id', m.measurement_unit_id, 'reverses_movement_id', m.reverses_movement_id,\n 'source_item_type', m.source_item_type, 'source_item_id', m.source_item_id)\n FROM public.plg_inventory_stock_movements m WHERE m.id = p_id;\n$$;\n\nCREATE FUNCTION app.inventory_movement_source_required() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nBEGIN\n IF NEW.source_location_id IS NULL THEN\n RAISE EXCEPTION 'inventory: a stock movement must name the location it moves from'\n USING ERRCODE = '23514';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_after_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_prev text := coalesce(current_setting('fayz.inventory_internal', true), 'off');\n r record;\nBEGIN\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n SELECT * INTO r FROM app.inventory_apply_to_position(\n NEW.tenant_id, NEW.product_id, NEW.source_location_id, NEW.batch_number, NEW.expiration_date,\n NEW.quantity, NEW.unit_cost, NEW.measurement_unit_id);\n PERFORM app.inventory_sync_archetype_balance(NEW.tenant_id, NEW.product_id, NEW.source_location_id, NEW.quantity);\n\n IF NEW.destination_location_id IS NOT NULL THEN\n SELECT * INTO r FROM app.inventory_apply_to_position(\n NEW.tenant_id, NEW.product_id, NEW.destination_location_id, NEW.batch_number, NEW.expiration_date,\n -NEW.quantity, NEW.unit_cost, NEW.measurement_unit_id);\n PERFORM app.inventory_sync_archetype_balance(NEW.tenant_id, NEW.product_id, NEW.destination_location_id, -NEW.quantity);\n END IF;\n\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_append_only() RETURNS trigger\n LANGUAGE plpgsql\n AS $$\nDECLARE\n v_old jsonb;\n v_new jsonb;\n k text;\nBEGIN\n IF TG_OP = 'UPDATE' THEN\n v_old := to_jsonb(OLD);\n v_new := to_jsonb(NEW);\n FOREACH k IN ARRAY ARRAY['unit_id', 'owner_id', 'updated_at'] LOOP\n v_old := v_old - k;\n v_new := v_new - k;\n END LOOP;\n -- GENERATED columns read NULL in NEW inside a BEFORE trigger: Postgres\n -- computes them after these run. This table has two \u2014 the compat mirrors\n -- 107 left behind for stock_location_id and movement_type \u2014 so comparing\n -- them would report a change on every row and refuse every backfill.\n -- They are derived from the columns already being compared anyway.\n FOR k IN\n SELECT a.attname FROM pg_attribute a\n WHERE a.attrelid = TG_RELID AND a.attnum > 0\n AND NOT a.attisdropped AND a.attgenerated <> ''\n LOOP\n v_old := v_old - k;\n v_new := v_new - k;\n END LOOP;\n IF v_old = v_new\n AND (OLD.unit_id IS NULL OR NEW.unit_id IS NOT DISTINCT FROM OLD.unit_id)\n AND (OLD.owner_id IS NULL OR NEW.owner_id IS NOT DISTINCT FROM OLD.owner_id) THEN\n RETURN NEW;\n END IF;\n END IF;\n RAISE EXCEPTION 'inventory: stock movements are append-only (write a reversal instead)' USING ERRCODE = '55000';\nEND $$;\n\nCREATE FUNCTION app.inventory_movements_before_insert() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_src record;\n v_dst record;\nBEGIN\n IF NEW.tenant_id IS NULL THEN\n RAISE EXCEPTION 'inventory: movement without tenant' USING ERRCODE = '22023';\n END IF;\n IF NEW.source_location_id IS NULL THEN\n RAISE EXCEPTION 'inventory: movement without a source location' USING ERRCODE = '22023';\n END IF;\n SELECT id, tenant_id, unit_id, is_active INTO v_src FROM public.plg_inventory_stock_locations WHERE id = NEW.source_location_id;\n IF v_src.id IS NULL OR v_src.tenant_id <> NEW.tenant_id THEN\n RAISE EXCEPTION 'inventory: source location % does not belong to the tenant', NEW.source_location_id USING ERRCODE = '22023';\n END IF;\n IF NEW.destination_location_id IS NOT NULL THEN\n SELECT id, tenant_id, unit_id, is_active INTO v_dst FROM public.plg_inventory_stock_locations WHERE id = NEW.destination_location_id;\n IF v_dst.id IS NULL OR v_dst.tenant_id <> NEW.tenant_id THEN\n RAISE EXCEPTION 'inventory: destination location % does not belong to the tenant', NEW.destination_location_id USING ERRCODE = '22023';\n END IF;\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = NEW.product_id AND p.tenant_id = NEW.tenant_id) THEN\n RAISE EXCEPTION 'inventory: product % does not belong to the tenant', NEW.product_id USING ERRCODE = '22023';\n END IF;\n -- the unit of the ledger row is the unit of the location whose stock moves first\n NEW.unit_id := v_src.unit_id;\n NEW.unit_cost := coalesce(NEW.unit_cost, 0);\n NEW.total_cost := abs(NEW.quantity) * NEW.unit_cost;\n NEW.user_id := coalesce(NEW.user_id, auth.uid());\n NEW.movement_date := coalesce(NEW.movement_date, current_date);\n NEW.metadata := coalesce(NEW.metadata, '{}'::jsonb);\n NEW.batch_number := nullif(btrim(NEW.batch_number), '');\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_parse_line(p_tenant uuid, p_line jsonb, p_mode text DEFAULT 'positive'::text, OUT product_id uuid, OUT quantity numeric, OUT unit_cost numeric, OUT batch_number text, OUT expiration_date date, OUT measurement_unit_id uuid, OUT declared jsonb, OUT notes text) RETURNS record\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_product uuid;\n v_declared_unit uuid;\n v_stock_unit uuid;\n v_declared_qty numeric;\n v_qty numeric;\n v_cost numeric;\n v_batch text;\n v_expiry date;\nBEGIN\n BEGIN\n v_product := (p_line->>'product_id')::uuid;\n v_declared_qty := coalesce((p_line->>'quantity')::numeric, (p_line->>'delta')::numeric);\n v_cost := (p_line->>'unit_cost')::numeric;\n v_batch := nullif(btrim(coalesce(p_line->>'batch_number', p_line->>'batch', '')), '');\n v_expiry := coalesce((p_line->>'expiration_date')::date, (p_line->>'expiry')::date);\n v_declared_unit := (p_line->>'measurement_unit_id')::uuid;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid line %: %', p_line, SQLERRM USING ERRCODE = '22023';\n END;\n IF v_product IS NULL THEN\n RAISE EXCEPTION 'inventory: line without product_id: %', p_line USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.products p WHERE p.id = v_product AND p.tenant_id = p_tenant) THEN\n RAISE EXCEPTION 'inventory: product % not found in this tenant', v_product USING ERRCODE = '22023';\n END IF;\n IF v_declared_qty IS NULL\n OR (p_mode = 'positive' AND v_declared_qty <= 0)\n OR (p_mode = 'signed' AND v_declared_qty = 0)\n OR (p_mode = 'absolute' AND v_declared_qty < 0) THEN\n RAISE EXCEPTION 'inventory: quantity must be % (line %)',\n CASE p_mode WHEN 'positive' THEN 'positive' WHEN 'signed' THEN 'non-zero' ELSE 'zero or positive' END, p_line USING ERRCODE = '22023';\n END IF;\n IF v_cost IS NOT NULL AND v_cost < 0 THEN\n RAISE EXCEPTION 'inventory: unit_cost cannot be negative (line %)', p_line USING ERRCODE = '22023';\n END IF;\n SELECT s.measurement_unit_id INTO v_stock_unit\n FROM public.plg_inventory_product_settings s\n WHERE s.tenant_id = p_tenant AND s.product_id = v_product AND s.unit_id IS NULL;\n v_qty := v_declared_qty;\n declared := '{}'::jsonb;\n IF v_declared_unit IS NOT NULL AND v_stock_unit IS NOT NULL AND v_declared_unit <> v_stock_unit THEN\n v_qty := public.inventory_convert(p_tenant, v_product, v_declared_qty, v_declared_unit, v_stock_unit);\n -- the cost was per declared unit; the ledger keeps cost per stock unit\n IF v_cost IS NOT NULL AND v_qty <> 0 THEN\n v_cost := round(v_cost * v_declared_qty / v_qty, 4);\n END IF;\n declared := jsonb_build_object('declared_quantity', v_declared_qty, 'declared_unit_id', v_declared_unit,\n 'declared_unit_cost', (p_line->>'unit_cost')::numeric);\n END IF;\n product_id := v_product;\n quantity := v_qty;\n unit_cost := v_cost;\n batch_number := v_batch;\n expiration_date := v_expiry;\n measurement_unit_id := coalesce(v_stock_unit, v_declared_unit);\n notes := p_line->>'notes';\nEND $$;\n\nCREATE FUNCTION app.inventory_position_json(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT coalesce((\n SELECT jsonb_build_object('product_id', p.product_id, 'stock_location_id', p.stock_location_id, 'unit_id', p.unit_id,\n 'batch_number', p.batch_number, 'expiration_date', p.expiration_date,\n 'quantity', p.quantity, 'unit_cost', p.unit_cost)\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = p_tenant AND p.product_id = p_product AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(p_batch, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(p_expiry, 'infinity'::date)),\n jsonb_build_object('product_id', p_product, 'stock_location_id', p_location, 'batch_number', p_batch, 'expiration_date', p_expiry, 'quantity', 0, 'unit_cost', 0));\n$$;\n\nCREATE FUNCTION app.inventory_product_label(p_product uuid) RETURNS TABLE(name text, sku text)\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT p.name, p.sku FROM public.products p\n WHERE p.id = p_product AND p.tenant_id = app.current_tenant_id();\n$$;\n\nCREATE FUNCTION app.inventory_require_tenant() RETURNS uuid\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n RETURN v_tenant;\nEND $$;\n\nCREATE FUNCTION app.inventory_reservation_json(p_id uuid) RETURNS jsonb\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\n SELECT jsonb_build_object(\n 'id', r.id, 'line_no', r.line_no, 'status', r.status, 'source_type', r.source_type, 'source_id', r.source_id,\n 'product_id', r.product_id, 'stock_location_id', r.stock_location_id, 'quantity', r.quantity,\n 'batch_number', r.batch_number, 'expiration_date', r.expiration_date,\n 'movement_id', r.movement_id, 'reversal_movement_id', r.reversal_movement_id)\n FROM public.plg_inventory_reservations r WHERE r.id = p_id;\n$$;\n\nCREATE FUNCTION app.inventory_reservation_one_per_source() RETURNS trigger\n LANGUAGE plpgsql\n SET search_path TO ''\n AS $$\nDECLARE v_existing record;\nBEGIN\n IF NEW.status = 'reversed' THEN\n RETURN NEW;\n END IF;\n SELECT r.id, r.idempotency_key, r.status INTO v_existing\n FROM public.plg_inventory_reservations r\n WHERE r.tenant_id = NEW.tenant_id\n AND r.source_type = NEW.source_type\n AND r.source_id = NEW.source_id\n AND r.line_no = NEW.line_no\n AND r.status <> 'reversed'\n AND r.id <> NEW.id\n LIMIT 1;\n IF FOUND THEN\n RAISE EXCEPTION\n 'inventory: % % line % is already consumed by reservation % (%) \u2014 reverse it before reserving again',\n NEW.source_type, NEW.source_id, NEW.line_no, v_existing.id, v_existing.status\n USING ERRCODE = '55000',\n HINT = 'inventory_reserve is idempotent by source item, not by idempotency key: a second key does not buy a second consumption (#203).';\n END IF;\n RETURN NEW;\nEND $$;\n\nCREATE FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) RETURNS void\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_variant uuid := app.inventory_default_variant(p_tenant, p_product);\n v_unit uuid;\nBEGIN\n IF v_variant IS NULL OR coalesce(p_delta, 0) = 0 THEN\n RETURN;\n END IF;\n SELECT l.unit_id INTO v_unit\n FROM public.plg_inventory_stock_locations l\n WHERE l.id = p_location AND l.tenant_id = p_tenant;\n\n PERFORM public.stock_apply(p_tenant, v_variant, v_unit, p_delta, 0, 'inventory');\nEND $$;\n\nCREATE FUNCTION app.trg_inventory_seed_units_for_tenant() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n PERFORM public.inventory_seed_measurement_units(NEW.id);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_denial jsonb;\n v_used int;\n v_recipe_id uuid;\n v_is_update boolean;\n v_name text;\n v_description text;\n v_instructions text;\n v_yield numeric;\n v_prep int;\n v_product_id uuid;\n v_product_hint text;\n v_item jsonb;\n v_iname text;\n v_qty numeric;\n v_pid uuid;\n v_order int := 0;\n v_resolved jsonb := '[]'::jsonb;\n v_unmatched jsonb := '[]'::jsonb;\n v_metadata jsonb;\n v_final_id uuid;\n v_final_name text;\nBEGIN\n -- \u2500\u2500 payload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n BEGIN\n v_recipe_id := NULLIF(p_payload->>'recipe_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n RETURN jsonb_build_object('ok', false, 'error', 'invalid recipe_id');\n END;\n v_is_update := v_recipe_id IS NOT NULL;\n v_name := btrim(p_payload->>'name');\n v_description := left(p_payload->>'description', 1000);\n v_instructions := left(p_payload->>'instructions', 8000);\n v_yield := COALESCE(NULLIF(p_payload->>'yield_quantity','')::numeric, 1);\n v_prep := NULLIF(p_payload->>'preparation_time_minutes','')::int;\n v_product_hint := btrim(COALESCE(p_payload->>'product_name',''));\n BEGIN\n v_product_id := NULLIF(p_payload->>'product_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_product_id := NULL;\n END;\n\n IF NOT v_is_update AND (v_name IS NULL OR v_name = '') THEN\n RETURN jsonb_build_object('ok', false, 'error', 'name is required to create a recipe');\n END IF;\n IF v_yield <= 0 THEN\n v_yield := 1;\n END IF;\n\n -- \u2500\u2500 authorization: role \u2192 plan \u2192 recipes cap \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n SELECT count(*) INTO v_used FROM plg_inventory_recipes\n WHERE tenant_id = p_tenant_id AND is_active = true;\n v_denial := agent_guard(p_tenant_id, p_actor_user_id, 'inventory.recipes',\n CASE WHEN v_is_update THEN 'update' ELSE 'create' END,\n 'recipes', v_used, CASE WHEN v_is_update THEN 0 ELSE 1 END);\n IF v_denial IS NOT NULL THEN\n RETURN jsonb_build_object('ok', false, 'denial', v_denial);\n END IF;\n\n IF v_is_update THEN\n PERFORM 1 FROM plg_inventory_recipes\n WHERE id = v_recipe_id AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown recipe for this tenant');\n END IF;\n END IF;\n\n -- \u2500\u2500 produced product: id must be this tenant's, else resolve by name \u2500\u2500\u2500\u2500\u2500\u2500\n IF v_product_id IS NOT NULL THEN\n PERFORM 1 FROM products WHERE id = v_product_id AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN v_product_id := NULL; END IF;\n END IF;\n IF v_product_id IS NULL AND v_product_hint <> '' THEN\n SELECT p.id INTO v_product_id FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND lower(btrim(p.name)) = lower(v_product_hint)\n ORDER BY p.is_active DESC LIMIT 1;\n IF v_product_id IS NULL THEN\n -- Containment rather than ILIKE: the name is user text and % / _ in it\n -- would be read as wildcards.\n SELECT p.id INTO v_product_id FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND position(lower(v_product_hint) in lower(p.name)) > 0\n ORDER BY length(p.name) LIMIT 1;\n END IF;\n END IF;\n\n -- \u2500\u2500 ingredients: name \u2192 products.id, unresolved ones set aside \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n FOR v_item IN SELECT value FROM jsonb_array_elements(COALESCE(p_payload->'ingredients','[]'::jsonb))\n LOOP\n v_iname := btrim(COALESCE(v_item->>'name', v_item->>'product_name', ''));\n v_qty := COALESCE(NULLIF(v_item->>'quantity','')::numeric, 0);\n BEGIN\n v_pid := NULLIF(v_item->>'product_id','')::uuid;\n EXCEPTION WHEN OTHERS THEN\n v_pid := NULL;\n END;\n\n IF v_pid IS NOT NULL THEN\n PERFORM 1 FROM products WHERE id = v_pid AND tenant_id = p_tenant_id;\n IF NOT FOUND THEN v_pid := NULL; END IF;\n END IF;\n\n IF v_pid IS NULL AND v_iname <> '' THEN\n SELECT p.id INTO v_pid FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND lower(btrim(p.name)) = lower(v_iname)\n ORDER BY p.is_active DESC LIMIT 1;\n IF v_pid IS NULL THEN\n SELECT p.id INTO v_pid FROM products p\n WHERE p.tenant_id = p_tenant_id\n AND position(lower(v_iname) in lower(p.name)) > 0\n ORDER BY length(p.name) LIMIT 1;\n END IF;\n END IF;\n\n IF v_pid IS NULL OR v_qty <= 0 THEN\n v_unmatched := v_unmatched || jsonb_build_object(\n 'name', v_iname, 'quantity', v_qty, 'notes', NULLIF(v_item->>'notes',''));\n ELSE\n v_resolved := v_resolved || jsonb_build_object(\n 'product_id', v_pid, 'quantity', v_qty,\n 'notes', NULLIF(v_item->>'notes',''), 'display_order', v_order);\n v_order := v_order + 1;\n END IF;\n END LOOP;\n\n v_metadata := jsonb_strip_nulls(jsonb_build_object(\n 'unmatchedIngredients',\n CASE WHEN jsonb_array_length(v_unmatched) > 0 THEN v_unmatched ELSE NULL END,\n 'productHint',\n CASE WHEN v_product_id IS NULL AND v_product_hint <> '' THEN v_product_hint ELSE NULL END\n ));\n\n -- \u2500\u2500 write + audit \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n IF v_is_update THEN\n UPDATE plg_inventory_recipes SET\n name = COALESCE(NULLIF(v_name,''), name),\n description = COALESCE(v_description, description),\n product_id = COALESCE(v_product_id, product_id),\n yield_quantity = COALESCE(NULLIF(NULLIF(p_payload->>'yield_quantity','')::numeric, 0), yield_quantity),\n preparation_time_minutes = COALESCE(v_prep, preparation_time_minutes),\n instructions = COALESCE(v_instructions, instructions),\n -- An edit that says nothing about ingredients must not erase the gaps the\n -- user still has to resolve.\n metadata = CASE WHEN p_payload ? 'ingredients' OR v_product_hint <> ''\n THEN v_metadata ELSE metadata END,\n updated_at = now()\n WHERE id = v_recipe_id AND tenant_id = p_tenant_id\n RETURNING id, name INTO v_final_id, v_final_name;\n ELSE\n INSERT INTO plg_inventory_recipes (\n tenant_id, name, description, product_id, yield_quantity,\n preparation_time_minutes, instructions, metadata\n ) VALUES (\n p_tenant_id, v_name, v_description, v_product_id, v_yield,\n v_prep, v_instructions, v_metadata\n )\n RETURNING id, name INTO v_final_id, v_final_name;\n END IF;\n\n -- `ingredients` REPLACES the list; omitting the key leaves the recipe's own.\n IF p_payload ? 'ingredients' THEN\n DELETE FROM plg_inventory_recipe_ingredients\n WHERE recipe_id = v_final_id AND tenant_id = p_tenant_id;\n INSERT INTO plg_inventory_recipe_ingredients (\n tenant_id, recipe_id, product_id, quantity, notes, display_order\n )\n SELECT p_tenant_id, v_final_id, (e->>'product_id')::uuid,\n (e->>'quantity')::numeric, NULLIF(e->>'notes',''), (e->>'display_order')::int\n FROM jsonb_array_elements(v_resolved) e;\n END IF;\n\n INSERT INTO audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (p_tenant_id, p_actor_user_id,\n CASE WHEN v_is_update THEN 'agent.updateRecipe' ELSE 'agent.createRecipe' END,\n 'inventory_recipe', v_final_id::text,\n jsonb_build_object('payload', p_payload));\n\n RETURN jsonb_build_object(\n 'ok', true,\n 'id', v_final_id,\n 'record', jsonb_build_object(\n 'ref', jsonb_build_object('id', v_final_id, 'resource', 'plg_inventory_recipes',\n 'archetype', 'inventory:recipe'),\n 'name', v_final_name,\n 'productId', v_product_id,\n 'ingredientCount', jsonb_array_length(v_resolved),\n 'unmatchedIngredients', v_unmatched,\n 'productHint', CASE WHEN v_product_id IS NULL THEN NULLIF(v_product_hint,'') ELSE NULL END\n )\n );\nEND;\n$$;\n\nCREATE FUNCTION public.app_product_stock_owner(p_tenant uuid) RETURNS text\n LANGUAGE sql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$ SELECT app.product_stock_owner(p_tenant) $$;\n\nCREATE FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'adjust:' || gen_random_uuid()::text);\n v_reason text := nullif(btrim(p_reason), '');\n v_op record;\n v_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_delta numeric;\n v_current numeric;\n v_target numeric;\n v_cost numeric;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: an adjustment needs a reason' USING ERRCODE = '22023';\n END IF;\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.edit');\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'adjust', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n IF v_line ? 'set_quantity' THEN\n -- absolute: turn into a delta against the current position (in the stock unit)\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, (v_line - 'set_quantity') || jsonb_build_object('quantity', v_line->'set_quantity'), 'absolute');\n v_target := v_ln.quantity;\n IF v_target < 0 THEN\n RAISE EXCEPTION 'inventory: set_quantity cannot be negative (line %)', v_line USING ERRCODE = '22023';\n END IF;\n SELECT coalesce(p.quantity, 0) INTO v_current FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n v_delta := v_target - coalesce(v_current, 0);\n IF v_delta = 0 THEN\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n CONTINUE;\n END IF;\n ELSE\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'signed');\n v_delta := v_ln.quantity;\n END IF;\n IF v_delta < 0 THEN\n IF app.inventory_available(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date) < -v_delta THEN\n RAISE EXCEPTION 'inventory: adjustment would take product % at location % below its available quantity',\n v_ln.product_id, p_location USING ERRCODE = '23514';\n END IF;\n END IF;\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, reason, notes, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'adjust', v_delta, coalesce(v_ln.unit_cost, v_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n 'adjustment', v_reason, v_ln.notes, v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'adjust', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'reason', v_reason, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'reason', v_reason, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_session plg_inventory_count_sessions%ROWTYPE;\n v_item plg_inventory_count_items%ROWTYPE;\n v_delta numeric;\n v_movement_id uuid;\n v_reason text;\n v_emitted int := 0;\nBEGIN\n -- The lock is what makes two simultaneous closes serialise instead of both\n -- reading `counting` and both emitting.\n SELECT * INTO v_session FROM plg_inventory_count_sessions\n WHERE id = p_session_id FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');\n END IF;\n IF v_session.tenant_id NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'inventory_close_count_session: not a member of this tenant';\n END IF;\n\n IF v_session.status = 'closed' THEN\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', true, 'adjustments_created', 0);\n END IF;\n IF v_session.status = 'cancelled' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');\n END IF;\n\n v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');\n\n FOR v_item IN\n SELECT * FROM plg_inventory_count_items\n WHERE session_id = p_session_id\n AND counted_quantity IS NOT NULL\n AND counted_quantity <> system_quantity\n AND movement_id IS NULL\n ORDER BY id\n FOR UPDATE\n LOOP\n v_delta := v_item.counted_quantity - v_item.system_quantity;\n\n INSERT INTO plg_inventory_stock_movements (\n tenant_id, product_id, quantity, movement_type, unit_cost, total_cost,\n stock_location_id, reason, movement_date, user_id, metadata\n ) VALUES (\n v_session.tenant_id, v_item.product_id, v_item.counted_quantity, 'adjustment',\n v_item.unit_cost, v_item.unit_cost * v_item.counted_quantity,\n v_session.stock_location_id, v_reason, CURRENT_DATE, auth.uid(),\n jsonb_build_object(\n 'countSessionId', p_session_id,\n 'countItemId', v_item.id,\n 'systemQuantity', v_item.system_quantity,\n 'countedQuantity', v_item.counted_quantity,\n 'variance', v_delta\n )\n )\n RETURNING id INTO v_movement_id;\n\n UPDATE plg_inventory_count_items\n SET movement_id = v_movement_id, updated_at = now()\n WHERE id = v_item.id;\n\n -- The variance lands on the untracked slot; batch slots keep their lots.\n INSERT INTO plg_inventory_stock_positions (\n tenant_id, product_id, stock_location_id, quantity, unit_cost\n ) VALUES (\n v_session.tenant_id, v_item.product_id, v_session.stock_location_id,\n v_delta, v_item.unit_cost\n )\n ON CONFLICT (tenant_id, product_id, stock_location_id, batch_number, expiration_date)\n DO UPDATE SET\n quantity = plg_inventory_stock_positions.quantity + EXCLUDED.quantity,\n updated_at = now();\n\n v_emitted := v_emitted + 1;\n END LOOP;\n\n UPDATE plg_inventory_count_sessions\n SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()\n WHERE id = p_session_id;\n\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', false, 'adjustments_created', v_emitted);\nEND;\n$$;\n\nCREATE FUNCTION public.inventory_confirm(p_idempotency_key text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := nullif(btrim(p_idempotency_key), '');\n v_op record;\n r record;\n v_unit uuid;\n v_id uuid;\n v_cost numeric;\n v_n integer := 0;\n v_movements jsonb := '[]'::jsonb;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_source_type text; v_source_id uuid;\nBEGIN\n IF v_key IS NULL THEN\n RAISE EXCEPTION 'inventory: idempotency_key is required' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key) THEN\n RAISE EXCEPTION 'inventory: no reservation with key % in this tenant', v_key USING ERRCODE = '22023';\n END IF;\n -- authorization first, before the operation row exists\n FOR r IN SELECT DISTINCT stock_location_id FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key LOOP\n PERFORM app.inventory_authorize_location(v_tenant, r.stock_location_id, 'inventory.edit');\n END LOOP;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'confirm', v_key, jsonb_build_object('idempotency_key', v_key));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR r IN SELECT * FROM public.plg_inventory_reservations x\n WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no FOR UPDATE LOOP\n v_source_type := r.source_type; v_source_id := r.source_id;\n IF r.status = 'reserved' THEN\n -- CAS: only the row still reserved becomes a movement\n UPDATE public.plg_inventory_reservations x\n SET status = 'confirmed', confirmed_at = now(), confirmed_by = auth.uid()\n WHERE x.id = r.id AND x.status = 'reserved';\n IF FOUND THEN\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = r.product_id AND p.stock_location_id = r.stock_location_id\n AND coalesce(p.batch_number, '') = coalesce(r.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(r.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, source_item_type, source_item_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'out', -r.quantity, coalesce(v_cost, 0), r.stock_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'consumption', r.source_type || ':' || r.source_id::text, r.source_type, r.source_id, v_key, r.line_no, v_op.op_id,\n coalesce(r.metadata, '{}'::jsonb) || jsonb_build_object('reservation_id', r.id))\n RETURNING id INTO v_id;\n UPDATE public.plg_inventory_reservations x SET movement_id = v_id WHERE x.id = r.id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_n := v_n + 1;\n END IF;\n ELSIF r.status = 'reversed' AND r.movement_id IS NULL THEN\n RAISE EXCEPTION 'inventory: reservation % line % was reversed before confirmation', v_key, r.line_no USING ERRCODE = '55000';\n END IF;\n v_reservations := v_reservations || app.inventory_reservation_json(r.id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.stock_location_id, r.batch_number, r.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'confirm', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'confirm', 'idempotency_key', v_key,\n 'source_type', v_source_type, 'source_id', v_source_id, 'status', 'confirmed',\n 'movements', v_movements, 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('source_type', v_source_type, 'source_id', v_source_id, 'confirmed_lines', v_n));\nEND $$;\n\nCREATE FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) RETURNS numeric\n LANGUAGE plpgsql STABLE SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_factor numeric;\n v_kind_from text; v_kind_to text;\n v_base uuid;\n v_f1 numeric; v_f2 numeric;\nBEGIN\n IF p_tenant IS NULL OR p_from_unit IS NULL OR p_to_unit IS NULL THEN\n RAISE EXCEPTION 'inventory_convert: tenant, from_unit and to_unit are required' USING ERRCODE = '22023';\n END IF;\n -- a signed-in session converts only within its own tenant\n IF auth.uid() IS NOT NULL AND p_tenant IS DISTINCT FROM app.current_tenant_id() THEN\n RAISE EXCEPTION 'inventory_convert: not your tenant' USING ERRCODE = '42501';\n END IF;\n IF p_from_unit = p_to_unit THEN RETURN p_qty; END IF;\n\n -- direct (product-specific first, then tenant-wide)\n SELECT c.factor INTO v_factor FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND c.from_unit_id = p_from_unit AND c.to_unit_id = p_to_unit\n AND (c.product_id = p_product OR c.product_id IS NULL)\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_factor IS NOT NULL THEN RETURN p_qty * v_factor; END IF;\n -- inverse\n SELECT c.factor INTO v_factor FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND c.from_unit_id = p_to_unit AND c.to_unit_id = p_from_unit\n AND (c.product_id = p_product OR c.product_id IS NULL)\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_factor IS NOT NULL THEN RETURN p_qty / v_factor; END IF;\n\n -- one hop through the base unit of the (shared) kind\n SELECT kind INTO v_kind_from FROM public.plg_inventory_measurement_units WHERE id = p_from_unit AND tenant_id = p_tenant;\n SELECT kind INTO v_kind_to FROM public.plg_inventory_measurement_units WHERE id = p_to_unit AND tenant_id = p_tenant;\n IF v_kind_from IS NULL OR v_kind_to IS NULL OR v_kind_from <> v_kind_to THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n SELECT id INTO v_base FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND kind = v_kind_from AND is_base;\n IF v_base IS NULL OR v_base IN (p_from_unit, p_to_unit) THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n -- from \u2192 base\n SELECT CASE WHEN c.from_unit_id = p_from_unit THEN c.factor ELSE 1 / c.factor END INTO v_f1\n FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND (c.product_id = p_product OR c.product_id IS NULL)\n AND ((c.from_unit_id = p_from_unit AND c.to_unit_id = v_base) OR (c.from_unit_id = v_base AND c.to_unit_id = p_from_unit))\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n -- base \u2192 to\n SELECT CASE WHEN c.from_unit_id = v_base THEN c.factor ELSE 1 / c.factor END INTO v_f2\n FROM public.plg_inventory_unit_conversions c\n WHERE c.tenant_id = p_tenant AND (c.product_id = p_product OR c.product_id IS NULL)\n AND ((c.from_unit_id = v_base AND c.to_unit_id = p_to_unit) OR (c.from_unit_id = p_to_unit AND c.to_unit_id = v_base))\n ORDER BY (c.product_id IS NOT NULL) DESC LIMIT 1;\n IF v_f1 IS NULL OR v_f2 IS NULL THEN\n RAISE EXCEPTION 'inventory_convert: no conversion from % to % (tenant %, product %)', p_from_unit, p_to_unit, p_tenant, p_product USING ERRCODE = '22023';\n END IF;\n RETURN p_qty * v_f1 * v_f2;\nEND $$;\n\nCREATE FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid DEFAULT NULL::uuid, p_reference text DEFAULT NULL::text, p_blind boolean DEFAULT true, p_notes text DEFAULT NULL::text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO 'public'\n AS $$\nDECLARE\n v_tenant uuid;\n v_session_id uuid;\nBEGIN\n SELECT tenant_id INTO v_tenant\n FROM plg_inventory_stock_locations WHERE id = p_stock_location_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory_open_count_session: unknown stock location %', p_stock_location_id;\n END IF;\n IF v_tenant NOT IN (SELECT public.user_tenant_ids()) THEN\n RAISE EXCEPTION 'inventory_open_count_session: not a member of this tenant';\n END IF;\n\n INSERT INTO plg_inventory_count_sessions (\n tenant_id, reference, status, stock_location_id, category_id, blind, notes, opened_by\n ) VALUES (\n v_tenant, NULLIF(btrim(COALESCE(p_reference, '')), ''), 'open',\n p_stock_location_id, p_category_id, COALESCE(p_blind, true),\n NULLIF(btrim(COALESCE(p_notes, '')), ''), auth.uid()\n )\n RETURNING id INTO v_session_id;\n\n -- Assets are patrimony, not stock, so they are never on a count sheet.\n INSERT INTO plg_inventory_count_items (\n tenant_id, session_id, product_id, system_quantity, unit_cost\n )\n SELECT\n v_tenant, v_session_id, p.id,\n COALESCE(pos.quantity, 0),\n COALESCE(pos.unit_cost, p.cost, 0)\n FROM products p\n LEFT JOIN plg_inventory_product_details d ON d.product_id = p.id\n LEFT JOIN LATERAL (\n SELECT sum(sp.quantity) AS quantity, max(sp.unit_cost) AS unit_cost\n FROM plg_inventory_stock_positions sp\n WHERE sp.tenant_id = v_tenant\n AND sp.product_id = p.id\n AND sp.stock_location_id = p_stock_location_id\n ) pos ON true\n WHERE p.tenant_id = v_tenant\n AND p.is_active = true\n AND COALESCE(p.metadata->>'productType', 'sale') <> 'asset'\n AND (p_category_id IS NULL OR d.category_id = p_category_id);\n\n RETURN v_session_id;\nEND;\n$$;\n\nCREATE FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'receive:' || gen_random_uuid()::text);\n v_op record;\n v_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_doc jsonb := coalesce(p_document, '{}'::jsonb);\n v_supplier uuid;\n v_date date;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n v_unit := app.inventory_authorize_location(v_tenant, p_location, 'inventory.create');\n BEGIN\n v_supplier := (v_doc->>'supplier_id')::uuid;\n v_date := (v_doc->>'date')::date;\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid document: %', SQLERRM USING ERRCODE = '22023';\n END;\n IF v_supplier IS NOT NULL AND NOT EXISTS (SELECT 1 FROM public.people s WHERE s.id = v_supplier AND s.tenant_id = v_tenant) THEN\n RAISE EXCEPTION 'inventory: supplier % not found in this tenant', v_supplier USING ERRCODE = '22023';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'receive', v_key,\n jsonb_build_object('location', p_location, 'lines', p_lines, 'document', v_doc));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, batch_number, expiration_date, measurement_unit_id,\n supplier_id, document_type, document_number, notes, movement_date, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'in', v_ln.quantity, coalesce(v_ln.unit_cost, 0), p_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n v_supplier, coalesce(v_doc->>'type', 'manual'), v_doc->>'ref', coalesce(v_ln.notes, v_doc->>'notes'), coalesce(v_date, current_date),\n v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'receive', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'receive', 'idempotency_key', v_key,\n 'location_id', p_location, 'unit_id', v_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('location_id', p_location, 'unit_id', v_unit, 'lines', v_no, 'document_type', coalesce(v_doc->>'type', 'manual')));\nEND $$;\n\nCREATE FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) RETURNS uuid\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.current_tenant_id();\n v_document uuid;\n v_prefix text;\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission_anywhere('inventory.edit') THEN\n RAISE EXCEPTION 'inventory: inventory.edit required to register a product image' USING ERRCODE = '42501';\n END IF;\n IF NOT EXISTS (\n SELECT 1 FROM public.products p\n WHERE p.id = p_product AND p.tenant_id = v_tenant AND p.kind = 'good'\n ) THEN\n RAISE EXCEPTION 'inventory: product not found in this tenant' USING ERRCODE = '22023';\n END IF;\n\n v_prefix := v_tenant::text || '/products/' || p_product::text || '/';\n IF nullif(btrim(p_storage_path), '') IS NULL OR p_storage_path NOT LIKE v_prefix || '%' THEN\n RAISE EXCEPTION 'inventory: product image path is outside its tenant/product prefix' USING ERRCODE = '22023';\n END IF;\n IF nullif(btrim(p_public_url), '') IS NULL THEN\n RAISE EXCEPTION 'inventory: product image URL is required' USING ERRCODE = '22023';\n END IF;\n IF p_file_size IS NULL OR p_file_size <= 0 OR p_file_size > 8388608 THEN\n RAISE EXCEPTION 'inventory: product image must be between 1 byte and 8 MB' USING ERRCODE = '22023';\n END IF;\n IF coalesce(p_mime_type, '') <> ALL (ARRAY['image/jpeg','image/png','image/webp','image/heic','image/heif']) THEN\n RAISE EXCEPTION 'inventory: unsupported product image type' USING ERRCODE = '22023';\n END IF;\n\n UPDATE public.documents\n SET is_active = false, status = 'archived', updated_at = now()\n WHERE tenant_id = v_tenant\n AND kind = 'inventory_product_image'\n AND subject_type = 'product'\n AND subject_id = p_product\n AND is_active;\n\n INSERT INTO public.documents (\n tenant_id, kind, title, status, file_url, file_name, file_size, mime_type,\n storage_provider, storage_bucket, storage_path, subject_type, subject_id,\n metadata, created_by, updated_by\n ) VALUES (\n v_tenant, 'inventory_product_image', coalesce(nullif(btrim(p_file_name), ''), 'Product image'),\n 'active', p_public_url, p_file_name, p_file_size, p_mime_type,\n 'supabase', 'inventory-images', p_storage_path, 'product', p_product,\n jsonb_build_object('plugin', 'inventory'), auth.uid(), auth.uid()\n ) RETURNING id INTO v_document;\n\n -- Compatibility projection for readers that have not moved to documents yet.\n UPDATE public.products\n SET image_url = p_public_url, updated_at = now()\n WHERE id = p_product AND tenant_id = v_tenant;\n\n INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)\n VALUES (\n v_tenant, auth.uid(), 'inventory.productImage.registered', 'document', v_document::text,\n jsonb_build_object('product_id', p_product, 'storage_path', p_storage_path)\n );\n\n RETURN v_document;\nEND $$;\n\nCREATE FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_source_type text := nullif(btrim(p_source_type), '');\n v_key text;\n v_op record;\n v_line jsonb;\n v_ln record;\n v_loc uuid;\n v_unit uuid;\n v_no integer := 0;\n v_id uuid;\n v_avail numeric;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n IF v_source_type IS NULL OR p_source_id IS NULL THEN\n RAISE EXCEPTION 'inventory: a reservation needs source_type and source_id' USING ERRCODE = '22023';\n END IF;\n v_key := coalesce(nullif(btrim(p_idempotency_key), ''), v_source_type || ':' || p_source_id::text);\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'reserve', v_key,\n jsonb_build_object('source_type', v_source_type, 'source_id', p_source_id, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n BEGIN\n v_loc := coalesce((v_line->>'location_id')::uuid, (v_line->>'stock_location_id')::uuid);\n EXCEPTION WHEN OTHERS THEN\n RAISE EXCEPTION 'inventory: invalid line %: %', v_line, SQLERRM USING ERRCODE = '22023';\n END;\n v_unit := app.inventory_authorize_location(v_tenant, v_loc, 'inventory.create');\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n v_avail := app.inventory_available(v_tenant, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date);\n IF v_avail < v_ln.quantity THEN\n RAISE EXCEPTION 'inventory: insufficient stock to reserve product % at location % (% available, % requested)',\n v_ln.product_id, v_loc, v_avail, v_ln.quantity USING ERRCODE = '23514';\n END IF;\n INSERT INTO public.plg_inventory_reservations\n (tenant_id, unit_id, idempotency_key, line_no, source_type, source_id, product_id, stock_location_id, batch_number, expiration_date,\n quantity, measurement_unit_id, status, metadata)\n VALUES\n (v_tenant, v_unit, v_key, v_no, v_source_type, p_source_id, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date,\n v_ln.quantity, v_ln.measurement_unit_id, 'reserved', v_ln.declared)\n RETURNING id INTO v_id;\n v_reservations := v_reservations || app.inventory_reservation_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, v_loc, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'reserve', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'reserve', 'idempotency_key', v_key,\n 'source_type', v_source_type, 'source_id', p_source_id, 'status', 'reserved',\n 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('source_type', v_source_type, 'source_id', p_source_id, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := nullif(btrim(p_idempotency_key), '');\n v_reason text := nullif(btrim(p_reason), '');\n v_op record;\n r record;\n v_id uuid;\n v_n integer := 0;\n v_no integer := 0;\n v_avail numeric;\n v_movements jsonb := '[]'::jsonb;\n v_reservations jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_had boolean := false;\nBEGIN\n IF v_key IS NULL THEN\n RAISE EXCEPTION 'inventory: idempotency_key is required' USING ERRCODE = '22023';\n END IF;\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a reversal needs a reason' USING ERRCODE = '22023';\n END IF;\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key)\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.kind <> 'reverse') THEN\n RAISE EXCEPTION 'inventory: nothing to reverse under key % in this tenant', v_key USING ERRCODE = '22023';\n END IF;\n -- authorization first: every unit touched by the key\n FOR r IN SELECT DISTINCT stock_location_id AS loc FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key\n UNION SELECT DISTINCT source_location_id FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key\n UNION SELECT DISTINCT destination_location_id FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.destination_location_id IS NOT NULL LOOP\n PERFORM app.inventory_authorize_location(v_tenant, r.loc, 'inventory.edit');\n END LOOP;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'reverse', v_key, jsonb_build_object('idempotency_key', v_key, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n -- 1. reservations under the key\n FOR r IN SELECT * FROM public.plg_inventory_reservations x\n WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no FOR UPDATE LOOP\n v_had := true;\n IF r.status = 'reserved' THEN\n UPDATE public.plg_inventory_reservations x SET status = 'reversed', reversed_at = now(), reversed_by = auth.uid(), reason = v_reason\n WHERE x.id = r.id AND x.status = 'reserved';\n v_n := v_n + 1;\n ELSIF r.status = 'confirmed' AND r.movement_id IS NOT NULL THEN\n SELECT m.* INTO STRICT r FROM public.plg_inventory_stock_movements m WHERE m.id = r.movement_id;\n v_no := v_no + 1;\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, reason, source_item_type, source_item_id, reverses_movement_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'reverse', -r.quantity, r.unit_cost, r.source_location_id, r.destination_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'reversal', r.document_number, v_reason, r.source_item_type, r.source_item_id, r.id, 'reverse:' || v_key, v_no, v_op.op_id,\n jsonb_build_object('reversed_movement_id', r.id))\n RETURNING id INTO v_id;\n UPDATE public.plg_inventory_reservations x\n SET status = 'reversed', reversed_at = now(), reversed_by = auth.uid(), reason = v_reason, reversal_movement_id = v_id\n WHERE x.movement_id = r.id AND x.status = 'confirmed';\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_n := v_n + 1;\n END IF;\n END LOOP;\n FOR r IN SELECT * FROM public.plg_inventory_reservations x WHERE x.tenant_id = v_tenant AND x.idempotency_key = v_key ORDER BY x.line_no LOOP\n v_reservations := v_reservations || app.inventory_reservation_json(r.id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.stock_location_id, r.batch_number, r.expiration_date);\n END LOOP;\n\n -- 2. plain movements under the key (receive / transfer / adjust), not yet reversed\n IF NOT v_had THEN\n FOR r IN SELECT m.* FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = v_tenant AND m.idempotency_key = v_key AND m.kind <> 'reverse'\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements x WHERE x.reverses_movement_id = m.id)\n ORDER BY m.line_no, m.created_at LOOP\n -- undoing an 'in' or a transfer's arrival must not go below what is still available\n IF r.quantity > 0 THEN\n v_avail := app.inventory_available(v_tenant, r.product_id, r.source_location_id, r.batch_number, r.expiration_date);\n IF v_avail < r.quantity THEN\n RAISE EXCEPTION 'inventory: cannot reverse movement % \u2014 only % of % still available at the source location', r.id, v_avail, r.quantity USING ERRCODE = '23514';\n END IF;\n END IF;\n IF r.destination_location_id IS NOT NULL AND r.quantity < 0 THEN\n v_avail := app.inventory_available(v_tenant, r.product_id, r.destination_location_id, r.batch_number, r.expiration_date);\n IF v_avail < -r.quantity THEN\n RAISE EXCEPTION 'inventory: cannot reverse transfer % \u2014 only % of % still available at the destination', r.id, v_avail, -r.quantity USING ERRCODE = '23514';\n END IF;\n END IF;\n v_no := v_no + 1;\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, document_number, reason, supplier_id, source_item_type, source_item_id, reverses_movement_id, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, r.product_id, 'reverse', -r.quantity, r.unit_cost, r.source_location_id, r.destination_location_id, r.batch_number, r.expiration_date, r.measurement_unit_id,\n 'reversal', r.document_number, v_reason, r.supplier_id, r.source_item_type, r.source_item_id, r.id, 'reverse:' || v_key, v_no, v_op.op_id,\n jsonb_build_object('reversed_movement_id', r.id, 'reversed_kind', r.kind))\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.source_location_id, r.batch_number, r.expiration_date);\n IF r.destination_location_id IS NOT NULL THEN\n v_positions := v_positions || app.inventory_position_json(v_tenant, r.product_id, r.destination_location_id, r.batch_number, r.expiration_date);\n END IF;\n v_n := v_n + 1;\n END LOOP;\n END IF;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'reverse', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'reverse', 'idempotency_key', v_key, 'reason', v_reason,\n 'status', 'reversed', 'reversed_lines', v_n,\n 'movements', v_movements, 'reservations', v_reservations, 'positions', v_positions),\n jsonb_build_object('reason', v_reason, 'reversed_lines', v_n));\nEND $$;\n\nCREATE FUNCTION public.inventory_seed_measurement_units() RETURNS integer\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid := app.current_tenant_id();\nBEGIN\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'inventory: no tenant in session' USING ERRCODE = '42501';\n END IF;\n IF NOT app.has_permission('inventory.manage') THEN\n RAISE EXCEPTION 'inventory: inventory.manage required' USING ERRCODE = '42501';\n END IF;\n RETURN public.inventory_seed_measurement_units(v_tenant);\nEND $$;\n\nCREATE FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) RETURNS integer\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_n integer := 0;\n v_ins integer;\n v_kg uuid; v_g uuid; v_l uuid; v_ml uuid;\nBEGIN\n IF p_tenant IS NULL THEN RETURN 0; END IF;\n WITH ins AS (\n INSERT INTO public.plg_inventory_measurement_units (tenant_id, name, abbreviation, code, kind, is_base, is_active)\n SELECT p_tenant, s.name, s.abbr, s.code, s.kind, s.is_base, true\n FROM (VALUES\n ('Unidade', 'un', 'un', 'unit', true),\n ('Caixa', 'cx', 'cx', 'unit', false),\n ('Quilograma', 'kg', 'kg', 'mass', false),\n ('Grama', 'g', 'g', 'mass', true),\n ('Litro', 'L', 'l', 'volume', false),\n ('Mililitro', 'mL', 'ml', 'volume', true)\n ) AS s(name, abbr, code, kind, is_base)\n WHERE NOT EXISTS (SELECT 1 FROM public.plg_inventory_measurement_units u WHERE u.tenant_id = p_tenant AND lower(u.code) = s.code)\n RETURNING 1\n ) SELECT count(*) INTO v_ins FROM ins;\n v_n := v_n + coalesce(v_ins, 0);\n\n SELECT id INTO v_kg FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'kg';\n SELECT id INTO v_g FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'g';\n SELECT id INTO v_l FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'l';\n SELECT id INTO v_ml FROM public.plg_inventory_measurement_units WHERE tenant_id = p_tenant AND lower(code) = 'ml';\n WITH ins AS (\n INSERT INTO public.plg_inventory_unit_conversions (tenant_id, from_unit_id, to_unit_id, factor)\n SELECT p_tenant, c.f, c.t, c.factor\n FROM (VALUES (v_kg, v_g, 1000::numeric), (v_l, v_ml, 1000::numeric)) AS c(f, t, factor)\n WHERE c.f IS NOT NULL AND c.t IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_unit_conversions x\n WHERE x.tenant_id = p_tenant AND x.from_unit_id = c.f AND x.to_unit_id = c.t AND x.product_id IS NULL)\n RETURNING 1\n ) SELECT count(*) INTO v_ins FROM ins;\n RETURN v_n + coalesce(v_ins, 0);\nEND $$;\n\nCREATE FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_key text := coalesce(nullif(btrim(p_idempotency_key), ''), 'transfer:' || gen_random_uuid()::text);\n v_op record;\n v_from_unit uuid; v_to_unit uuid;\n v_line jsonb;\n v_ln record;\n v_no integer := 0;\n v_id uuid;\n v_avail numeric;\n v_cost numeric;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\nBEGIN\n PERFORM app.inventory_check_lines(p_lines);\n IF p_from_location IS NULL OR p_to_location IS NULL OR p_from_location = p_to_location THEN\n RAISE EXCEPTION 'inventory: a transfer needs two different stock locations' USING ERRCODE = '22023';\n END IF;\n v_from_unit := app.inventory_authorize_location(v_tenant, p_from_location, 'inventory.edit');\n v_to_unit := app.inventory_authorize_location(v_tenant, p_to_location, 'inventory.create');\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(v_tenant, 'transfer', v_key,\n jsonb_build_object('from_location', p_from_location, 'to_location', p_to_location, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN RETURN v_op.existing; END IF;\n\n FOR v_line IN SELECT * FROM jsonb_array_elements(p_lines) LOOP\n v_no := v_no + 1;\n SELECT * INTO v_ln FROM app.inventory_parse_line(v_tenant, v_line, 'positive');\n v_avail := app.inventory_available(v_tenant, v_ln.product_id, p_from_location, v_ln.batch_number, v_ln.expiration_date);\n IF v_avail < v_ln.quantity THEN\n RAISE EXCEPTION 'inventory: insufficient stock to transfer product % from location % (% available, % requested)',\n v_ln.product_id, p_from_location, v_avail, v_ln.quantity USING ERRCODE = '23514';\n END IF;\n SELECT p.unit_cost INTO v_cost FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant AND p.product_id = v_ln.product_id AND p.stock_location_id = p_from_location\n AND coalesce(p.batch_number, '') = coalesce(v_ln.batch_number, '')\n AND coalesce(p.expiration_date, 'infinity'::date) = coalesce(v_ln.expiration_date, 'infinity'::date);\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, destination_location_id, batch_number, expiration_date, measurement_unit_id,\n document_type, notes, idempotency_key, line_no, operation_id, metadata)\n VALUES\n (v_tenant, v_ln.product_id, 'transfer', -v_ln.quantity, coalesce(v_cost, 0), p_from_location, p_to_location, v_ln.batch_number, v_ln.expiration_date, v_ln.measurement_unit_id,\n 'transfer', v_ln.notes, v_key, v_no, v_op.op_id, v_ln.declared)\n RETURNING id INTO v_id;\n v_movements := v_movements || app.inventory_movement_json(v_id);\n v_positions := v_positions || app.inventory_position_json(v_tenant, v_ln.product_id, p_from_location, v_ln.batch_number, v_ln.expiration_date)\n || app.inventory_position_json(v_tenant, v_ln.product_id, p_to_location, v_ln.batch_number, v_ln.expiration_date);\n END LOOP;\n\n RETURN app.inventory_finish_operation(v_tenant, v_op.op_id, 'transfer', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'transfer', 'idempotency_key', v_key,\n 'from_location_id', p_from_location, 'to_location_id', p_to_location,\n 'from_unit_id', v_from_unit, 'to_unit_id', v_to_unit, 'movements', v_movements, 'positions', v_positions),\n jsonb_build_object('from_location_id', p_from_location, 'to_location_id', p_to_location, 'lines', v_no));\nEND $$;\n\nCREATE FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_ful_id uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_actor uuid := nullif(p_event ->> 'actor_id', '')::uuid;\n v_ful public.fulfillments%ROWTYPE;\n v_line record;\n v_result jsonb;\n v_results jsonb := '[]'::jsonb;\n v_count integer := 0;\nBEGIN\n IF v_tenant IS NULL OR v_ful_id IS NULL THEN\n RAISE EXCEPTION 'fulfillment.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF public.app_product_stock_owner(v_tenant) <> 'inventory' THEN\n RETURN jsonb_build_object('skipped', 'stock owner is not inventory for this tenant');\n END IF;\n\n SELECT * INTO v_ful FROM public.fulfillments f WHERE f.id = v_ful_id AND f.tenant_id = v_tenant;\n IF v_ful.id IS NULL THEN\n RETURN jsonb_build_object('skipped', 'fulfillment not found');\n END IF;\n IF v_ful.status NOT IN ('delivered', 'completed') THEN\n RETURN jsonb_build_object('skipped', 'delivery is no longer completed', 'current_status', v_ful.status);\n END IF;\n\n FOR v_line IN\n SELECT i.id, i.product_id, i.quantity\n FROM public.items i\n JOIN public.products p ON p.id = i.product_id AND p.tenant_id = v_tenant\n WHERE i.fulfillment_id = v_ful.id AND i.tenant_id = v_tenant\n AND i.product_id IS NOT NULL AND coalesce(i.quantity, 0) > 0\n AND p.kind = 'good'\n ORDER BY i.sort_order, i.created_at, i.id\n LOOP\n -- The DELIVERY line is the idempotency source, not the order line: two\n -- deliveries of the same order line are two movements, and re-running this\n -- handler is none.\n v_result := app.inventory_consume_order_item(\n v_tenant, v_ful.order_id, v_line.id, v_ful.unit_id,\n v_line.product_id, v_line.quantity, v_actor);\n v_results := v_results || v_result;\n v_count := v_count + 1;\n END LOOP;\n\n RETURN jsonb_build_object('fulfillment_id', v_ful.id, 'order_id', v_ful.order_id,\n 'consumed_items', v_count, 'results', v_results);\nEND $$;\n\nCREATE FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_order uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_actor uuid := nullif(p_event ->> 'actor_id', '')::uuid;\n v_order_row record;\n v_item record;\n v_result jsonb;\n v_results jsonb := '[]'::jsonb;\n v_count integer := 0;\n v_delivered integer := 0;\nBEGIN\n IF v_tenant IS NULL OR v_order IS NULL THEN\n RAISE EXCEPTION 'order.completed carried no tenant or subject id' USING ERRCODE = '22023';\n END IF;\n IF public.app_product_stock_owner(v_tenant) <> 'inventory' THEN\n RETURN jsonb_build_object('skipped', 'stock owner is not inventory for this tenant');\n END IF;\n\n SELECT o.status, o.unit_id INTO v_order_row\n FROM public.orders o WHERE o.id = v_order AND o.tenant_id = v_tenant;\n IF v_order_row.status IS NULL THEN\n RAISE EXCEPTION 'inventory: order % was not found for tenant %', v_order, v_tenant\n USING ERRCODE = '22023';\n END IF;\n IF v_order_row.status <> 'completed' THEN\n RETURN jsonb_build_object('skipped', 'order is no longer completed',\n 'order_id', v_order, 'current_status', v_order_row.status);\n END IF;\n\n FOR v_item IN\n SELECT oi.id, oi.product_id, oi.quantity\n FROM public.items oi\n JOIN public.products p ON p.id = oi.product_id AND p.tenant_id = v_tenant\n WHERE oi.order_id = v_order AND oi.tenant_id = v_tenant\n AND oi.cancelled_at IS NULL\n AND oi.product_id IS NOT NULL AND coalesce(oi.quantity, 0) > 0\n AND p.kind = 'good'\n ORDER BY oi.sort_order, oi.created_at, oi.id\n LOOP\n -- The delivery is the movement where a delivery exists.\n IF EXISTS (\n SELECT 1\n FROM public.item_associations a\n JOIN public.items t ON t.id = a.to_item_id\n WHERE a.from_item_id = v_item.id AND t.fulfillment_id IS NOT NULL\n ) THEN\n v_delivered := v_delivered + 1;\n CONTINUE;\n END IF;\n\n v_result := app.inventory_consume_order_item(\n v_tenant, v_order, v_item.id, v_order_row.unit_id,\n v_item.product_id, v_item.quantity, v_actor\n );\n v_results := v_results || v_result;\n v_count := v_count + 1;\n END LOOP;\n\n RETURN jsonb_build_object('order_id', v_order, 'consumed_items', v_count,\n 'delivered_elsewhere', v_delivered, 'results', v_results);\nEND $$;\n\nCREATE FUNCTION public.trg_fulfillments_emit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nBEGIN\n IF NEW.status NOT IN ('delivered', 'completed') THEN RETURN NULL; END IF;\n IF TG_OP = 'UPDATE' AND OLD.status IN ('delivered', 'completed') THEN RETURN NULL; END IF;\n\n PERFORM public.plg_emit_event(\n 'fulfillment.completed',\n jsonb_build_object(\n 'fulfillment_id', NEW.id,\n 'order_id', NEW.order_id,\n 'kind', NEW.kind,\n 'unit_id', NEW.unit_id,\n 'occurred_at', coalesce(NEW.occurred_at, now())\n ),\n 'fulfillment', NEW.id::text, NEW.tenant_id, NULL);\n RETURN NULL;\nEND $$;\n\nCREATE FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() RETURNS trigger\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE v_tenant uuid; v_unit uuid;\nBEGIN\n SELECT i.tenant_id, i.unit_id INTO v_tenant, v_unit FROM public.items i WHERE i.id = NEW.item_id;\n IF v_tenant IS NULL THEN\n RAISE EXCEPTION 'plg_inventory_fulfillment_items: line % not found', NEW.item_id USING ERRCODE = '23503';\n END IF;\n NEW.tenant_id := v_tenant;\n IF NEW.unit_id IS NULL THEN NEW.unit_id := v_unit; END IF;\n RETURN NEW;\nEND $$;\n\n\n-- \u2500\u2500 constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_pkey PRIMARY KEY (item_id);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_pkey PRIMARY KEY (product_id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_tenant_id_id_key UNIQUE (tenant_id, id);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_pkey PRIMARY KEY (id);\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_tenant_id_id_key UNIQUE (tenant_id, id);\n\n\n-- \u2500\u2500 view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE VIEW public.v_inventory_balances WITH (security_invoker='true') AS\n SELECT p.id,\n p.tenant_id,\n p.unit_id,\n p.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n p.stock_location_id,\n l.name AS location_name,\n p.batch_number,\n p.expiration_date,\n p.quantity,\n COALESCE(r.reserved, (0)::numeric) AS reserved_quantity,\n (p.quantity - COALESCE(r.reserved, (0)::numeric)) AS available_quantity,\n p.unit_cost,\n (p.quantity * p.unit_cost) AS total_value,\n p.measurement_unit_id,\n mu.code AS measurement_unit_code,\n p.created_at,\n p.updated_at\n FROM ((((public.plg_inventory_stock_positions p\n LEFT JOIN public.plg_inventory_stock_locations l ON ((l.id = p.stock_location_id)))\n LEFT JOIN public.plg_inventory_measurement_units mu ON ((mu.id = p.measurement_unit_id)))\n LEFT JOIN LATERAL app.inventory_product_label(p.product_id) pl(name, sku) ON (true))\n LEFT JOIN LATERAL ( SELECT sum(x.quantity) AS reserved\n FROM public.plg_inventory_reservations x\n WHERE ((x.tenant_id = p.tenant_id) AND (x.product_id = p.product_id) AND (x.stock_location_id = p.stock_location_id) AND (COALESCE(x.batch_number, ''::text) = COALESCE(p.batch_number, ''::text)) AND (COALESCE(x.expiration_date, 'infinity'::date) = COALESCE(p.expiration_date, 'infinity'::date)) AND (x.status = 'reserved'::text))) r ON (true));\n\nCREATE VIEW public.v_inventory_low_stock WITH (security_invoker='true') AS\n WITH per_unit AS (\n SELECT b.tenant_id,\n b.product_id,\n b.unit_id,\n sum(b.quantity) AS on_hand,\n sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b\n GROUP BY b.tenant_id, b.product_id, b.unit_id\n ), tenant_wide AS (\n SELECT b.tenant_id,\n b.product_id,\n NULL::uuid AS unit_id,\n sum(b.quantity) AS on_hand,\n sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b\n GROUP BY b.tenant_id, b.product_id\n ), scopes AS (\n SELECT per_unit.tenant_id,\n per_unit.product_id,\n per_unit.unit_id,\n per_unit.on_hand,\n per_unit.available_quantity\n FROM per_unit\n UNION ALL\n SELECT tenant_wide.tenant_id,\n tenant_wide.product_id,\n tenant_wide.unit_id,\n tenant_wide.on_hand,\n tenant_wide.available_quantity\n FROM tenant_wide\n ), settings AS (\n SELECT s_1.tenant_id,\n s_1.product_id,\n s_1.unit_id,\n s_1.min_quantity,\n s_1.max_quantity\n FROM public.plg_inventory_product_settings s_1\n )\n SELECT s.tenant_id,\n s.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n s.unit_id,\n COALESCE(sc.on_hand, (0)::numeric) AS on_hand,\n COALESCE(sc.available_quantity, (0)::numeric) AS available_quantity,\n s.min_quantity,\n s.max_quantity,\n (s.min_quantity - COALESCE(sc.on_hand, (0)::numeric)) AS shortfall\n FROM ((settings s\n LEFT JOIN scopes sc ON (((sc.tenant_id = s.tenant_id) AND (sc.product_id = s.product_id) AND (NOT (sc.unit_id IS DISTINCT FROM s.unit_id)))))\n LEFT JOIN LATERAL app.inventory_product_label(s.product_id) pl(name, sku) ON (true))\n WHERE ((s.min_quantity > (0)::numeric) AND (COALESCE(sc.on_hand, (0)::numeric) <= s.min_quantity));\n\nCREATE VIEW public.v_inventory_movements WITH (security_invoker='true') AS\n SELECT m.id,\n m.tenant_id,\n m.unit_id,\n m.product_id,\n pl.name AS product_name,\n pl.sku AS product_sku,\n m.kind,\n CASE m.kind\n WHEN 'in'::text THEN 'entry'::text\n WHEN 'out'::text THEN\n CASE\n WHEN ((m.reason ~~* 'loss%'::text) OR (m.document_type = 'loss'::text)) THEN 'loss'::text\n ELSE 'exit'::text\n END\n WHEN 'transfer'::text THEN 'transfer'::text\n WHEN 'adjust'::text THEN 'adjustment'::text\n WHEN 'reverse'::text THEN 'adjustment'::text\n ELSE NULL::text\n END AS movement_type,\n m.quantity,\n abs(m.quantity) AS abs_quantity,\n m.unit_cost,\n m.total_cost,\n m.source_location_id,\n sl.name AS source_location_name,\n m.destination_location_id,\n dl.name AS destination_location_name,\n m.batch_number,\n m.expiration_date,\n m.measurement_unit_id,\n mu.code AS measurement_unit_code,\n m.supplier_id,\n sp.name AS supplier_name,\n m.document_type,\n m.document_number,\n m.reason,\n m.notes,\n m.movement_date,\n m.user_id,\n m.reverses_movement_id,\n m.idempotency_key,\n m.line_no,\n m.operation_id,\n m.source_item_type,\n m.source_item_id,\n m.metadata,\n m.created_at\n FROM (((((public.plg_inventory_stock_movements m\n LEFT JOIN LATERAL app.inventory_product_label(m.product_id) pl(name, sku) ON (true))\n LEFT JOIN public.plg_inventory_stock_locations sl ON ((sl.id = m.source_location_id)))\n LEFT JOIN public.plg_inventory_stock_locations dl ON ((dl.id = m.destination_location_id)))\n LEFT JOIN public.plg_inventory_measurement_units mu ON ((mu.id = m.measurement_unit_id)))\n LEFT JOIN public.people sp ON ((sp.id = m.supplier_id)));\n\nCREATE VIEW public.v_inventory_product_totals WITH (security_invoker='true') AS\n SELECT tenant_id,\n product_id,\n max(product_name) AS product_name,\n max(product_sku) AS product_sku,\n sum(quantity) AS on_hand,\n sum(reserved_quantity) AS reserved_quantity,\n sum(available_quantity) AS available_quantity,\n CASE\n WHEN (sum(quantity) > (0)::numeric) THEN round((sum((quantity * unit_cost)) / sum(quantity)), 4)\n ELSE max(unit_cost)\n END AS avg_unit_cost,\n sum(total_value) AS total_value,\n (count(DISTINCT stock_location_id))::integer AS location_count,\n (count(*))::integer AS position_count,\n min(expiration_date) AS next_expiration,\n max(updated_at) AS updated_at\n FROM public.v_inventory_balances b\n GROUP BY tenant_id, product_id;\n\nCREATE VIEW public.v_inventory_products WITH (security_invoker='true') AS\n SELECT p.id,\n p.tenant_id,\n p.category_id,\n p.name,\n p.description,\n p.sku,\n p.price,\n p.cost,\n p.currency,\n p.unit,\n p.image_url,\n p.status,\n p.is_active,\n p.tags,\n p.metadata,\n p.created_at,\n p.updated_at,\n p.created_by,\n p.custom_fields,\n p.unit_id,\n p.owner_id,\n p.kind,\n p.subkind,\n p.duration_minutes,\n p.service_kind,\n COALESCE(t.on_hand, (0)::numeric) AS stock,\n COALESCE(t.available_quantity, (0)::numeric) AS available,\n COALESCE(t.reserved_quantity, (0)::numeric) AS reserved,\n COALESCE(s.min_quantity, (0)::numeric) AS min_stock,\n s.max_quantity AS max_stock,\n t.location_count,\n t.next_expiration\n FROM ((public.products p\n LEFT JOIN public.v_inventory_product_totals t ON (((t.tenant_id = p.tenant_id) AND (t.product_id = p.id))))\n LEFT JOIN public.plg_inventory_product_settings s ON (((s.tenant_id = p.tenant_id) AND (s.product_id = p.id) AND (s.unit_id IS NULL))));\n\nCREATE VIEW public.v_stock_movements WITH (security_invoker='true') AS\n SELECT sm.id,\n sm.tenant_id,\n sm.product_id,\n sm.quantity,\n sm.kind AS movement_type,\n sm.unit_cost,\n sm.total_cost,\n sm.source_location_id AS stock_location_id,\n sm.destination_location_id,\n sm.supplier_id,\n sm.document_number,\n sm.reason,\n sm.notes,\n sm.movement_date,\n sm.user_id,\n sm.batch_number,\n sm.expiration_date,\n sm.metadata,\n sm.created_at,\n p.name AS product_name,\n p.sku AS product_sku,\n sl.name AS stock_location_name,\n dl.name AS destination_location_name\n FROM (((public.plg_inventory_stock_movements sm\n LEFT JOIN public.products p ON ((p.id = sm.product_id)))\n LEFT JOIN public.plg_inventory_stock_locations sl ON ((sl.id = sm.source_location_id)))\n LEFT JOIN public.plg_inventory_stock_locations dl ON ((dl.id = sm.destination_location_id)));\n\n\n-- \u2500\u2500 index \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE INDEX idx_plg_inventory_count_items_tenant ON public.plg_inventory_count_items USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_count_sessions_status ON public.plg_inventory_count_sessions USING btree (tenant_id, status);\n\nCREATE INDEX idx_plg_inventory_count_sessions_tenant ON public.plg_inventory_count_sessions USING btree (tenant_id, opened_at DESC);\n\nCREATE INDEX idx_plg_inventory_measurement_units_tenant ON public.plg_inventory_measurement_units USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_product_categories_tenant ON public.plg_inventory_product_categories USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_product_details_category ON public.plg_inventory_product_details USING btree (category_id);\n\nCREATE INDEX idx_plg_inventory_product_details_supplier ON public.plg_inventory_product_details USING btree (supplier_id);\n\nCREATE INDEX idx_plg_inventory_product_details_tenant ON public.plg_inventory_product_details USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipe_groups_recipe ON public.plg_inventory_recipe_groups USING btree (recipe_id);\n\nCREATE INDEX idx_plg_inventory_recipe_groups_tenant ON public.plg_inventory_recipe_groups USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_group ON public.plg_inventory_recipe_ingredients USING btree (group_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_recipe ON public.plg_inventory_recipe_ingredients USING btree (recipe_id);\n\nCREATE INDEX idx_plg_inventory_recipe_ingredients_tenant ON public.plg_inventory_recipe_ingredients USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_recipes_product ON public.plg_inventory_recipes USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_recipes_tenant ON public.plg_inventory_recipes USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_locations_tenant ON public.plg_inventory_stock_locations USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_movements_date ON public.plg_inventory_stock_movements USING btree (tenant_id, movement_date);\n\nCREATE INDEX idx_plg_inventory_stock_movements_product ON public.plg_inventory_stock_movements USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_stock_movements_tenant ON public.plg_inventory_stock_movements USING btree (tenant_id);\n\nCREATE INDEX idx_plg_inventory_stock_positions_product ON public.plg_inventory_stock_positions USING btree (product_id);\n\nCREATE INDEX idx_plg_inventory_stock_positions_tenant ON public.plg_inventory_stock_positions USING btree (tenant_id);\n\nCREATE INDEX plg_inventory_count_items_tenant_owner_idx ON public.plg_inventory_count_items USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_count_items_tenant_unit_idx ON public.plg_inventory_count_items USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_count_sessions_tenant_owner_idx ON public.plg_inventory_count_sessions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_count_sessions_tenant_unit_idx ON public.plg_inventory_count_sessions USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_fulfillment_items_batch_idx ON public.plg_inventory_fulfillment_items USING btree (tenant_id, batch_number) WHERE (batch_number IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_measurement_units_base_key ON public.plg_inventory_measurement_units USING btree (tenant_id, kind) WHERE is_base;\n\nCREATE UNIQUE INDEX plg_inventory_measurement_units_code_key ON public.plg_inventory_measurement_units USING btree (tenant_id, lower(code)) WHERE (code IS NOT NULL);\n\nCREATE INDEX plg_inventory_measurement_units_tenant_owner_idx ON public.plg_inventory_measurement_units USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_measurement_units_tenant_unit_idx ON public.plg_inventory_measurement_units USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_operations_key ON public.plg_inventory_operations USING btree (tenant_id, kind, idempotency_key);\n\nCREATE INDEX plg_inventory_operations_tenant_owner_idx ON public.plg_inventory_operations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_operations_tenant_unit_idx ON public.plg_inventory_operations USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_product_categories_tenant_owner_idx ON public.plg_inventory_product_categories USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_product_categories_tenant_unit_idx ON public.plg_inventory_product_categories USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_product_settings_product_unit_key ON public.plg_inventory_product_settings USING btree (tenant_id, product_id, COALESCE(unit_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\nCREATE INDEX plg_inventory_product_settings_tenant_owner_idx ON public.plg_inventory_product_settings USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_product_settings_tenant_unit_idx ON public.plg_inventory_product_settings USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_recipes_tenant_owner_idx ON public.plg_inventory_recipes USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_recipes_tenant_unit_idx ON public.plg_inventory_recipes USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_reservations_key_line ON public.plg_inventory_reservations USING btree (tenant_id, idempotency_key, line_no);\n\nCREATE UNIQUE INDEX plg_inventory_reservations_one_live_per_source_uidx ON public.plg_inventory_reservations USING btree (tenant_id, source_type, source_id, line_no) WHERE (status <> 'reversed'::text);\n\nCREATE INDEX plg_inventory_reservations_open_idx ON public.plg_inventory_reservations USING btree (tenant_id, product_id, stock_location_id) WHERE (status = 'reserved'::text);\n\nCREATE INDEX plg_inventory_reservations_source_idx ON public.plg_inventory_reservations USING btree (tenant_id, source_type, source_id);\n\nCREATE INDEX plg_inventory_reservations_tenant_owner_idx ON public.plg_inventory_reservations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_reservations_tenant_unit_idx ON public.plg_inventory_reservations USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_stock_locations_tenant_owner_idx ON public.plg_inventory_stock_locations USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_locations_tenant_unit_idx ON public.plg_inventory_stock_locations USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_stock_locations_unit_name_key ON public.plg_inventory_stock_locations USING btree (tenant_id, unit_id, lower(name));\n\nCREATE INDEX plg_inventory_stock_movements_created_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, created_at DESC);\n\nCREATE INDEX plg_inventory_stock_movements_dest_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, destination_location_id, product_id) WHERE (destination_location_id IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_movements_idem_key ON public.plg_inventory_stock_movements USING btree (tenant_id, idempotency_key, line_no) WHERE (idempotency_key IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_movements_reverses_key ON public.plg_inventory_stock_movements USING btree (reverses_movement_id) WHERE (reverses_movement_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_source_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, source_location_id, product_id);\n\nCREATE INDEX plg_inventory_stock_movements_source_item_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, source_item_type, source_item_id) WHERE (source_item_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_tenant_owner_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_movements_tenant_unit_idx ON public.plg_inventory_stock_movements USING btree (tenant_id, unit_id);\n\nCREATE INDEX plg_inventory_stock_positions_expiry_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, expiration_date) WHERE (expiration_date IS NOT NULL);\n\nCREATE UNIQUE INDEX plg_inventory_stock_positions_identity_key ON public.plg_inventory_stock_positions USING btree (tenant_id, product_id, stock_location_id, COALESCE(batch_number, ''::text), COALESCE(expiration_date, 'infinity'::date));\n\nCREATE INDEX plg_inventory_stock_positions_location_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, stock_location_id, product_id);\n\nCREATE INDEX plg_inventory_stock_positions_tenant_owner_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_stock_positions_tenant_unit_idx ON public.plg_inventory_stock_positions USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX plg_inventory_unit_conversions_key ON public.plg_inventory_unit_conversions USING btree (tenant_id, from_unit_id, to_unit_id, COALESCE(product_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\nCREATE INDEX plg_inventory_unit_conversions_product_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, product_id) WHERE (product_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_unit_conversions_tenant_owner_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, owner_id) WHERE (owner_id IS NOT NULL);\n\nCREATE INDEX plg_inventory_unit_conversions_tenant_unit_idx ON public.plg_inventory_unit_conversions USING btree (tenant_id, unit_id);\n\nCREATE UNIQUE INDEX uq_plg_inventory_count_items_line ON public.plg_inventory_count_items USING btree (session_id, product_id);\n\nCREATE UNIQUE INDEX uq_plg_inventory_stock_positions_slot ON public.plg_inventory_stock_positions USING btree (tenant_id, product_id, stock_location_id, batch_number, expiration_date) NULLS NOT DISTINCT;\n\n\n-- \u2500\u2500 fk constraint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_movement_id_fkey FOREIGN KEY (movement_id) REFERENCES public.plg_inventory_stock_movements(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.plg_inventory_count_sessions(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_items\n ADD CONSTRAINT plg_inventory_count_items_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_item_id_fkey FOREIGN KEY (item_id) REFERENCES public.items(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_parent_fk FOREIGN KEY (item_id, parent_kind) REFERENCES public.items(id, parent_kind) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_fulfillment_items\n ADD CONSTRAINT plg_inventory_fulfillment_items_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_operations\n ADD CONSTRAINT plg_inventory_operations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.plg_inventory_product_categories(id);\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_categories\n ADD CONSTRAINT plg_inventory_product_categories_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_default_location_id_fkey FOREIGN KEY (default_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_purchase_unit_id_fkey FOREIGN KEY (purchase_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_supplier_id_fkey FOREIGN KEY (supplier_id) REFERENCES public.people(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_details\n ADD CONSTRAINT plg_inventory_product_details_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_default_location_id_fkey FOREIGN KEY (default_location_id) REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_product_settings\n ADD CONSTRAINT plg_inventory_product_settings_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_recipe_id_fkey FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_groups\n ADD CONSTRAINT plg_inventory_recipe_groups_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.plg_inventory_recipe_groups(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_recipe_id_fkey FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipe_ingredients\n ADD CONSTRAINT plg_inventory_recipe_ingredients_unit_fk FOREIGN KEY (unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_recipes\n ADD CONSTRAINT plg_inventory_recipes_yield_unit_fk FOREIGN KEY (yield_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_movement_id_fkey FOREIGN KEY (movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_reversal_movement_id_fkey FOREIGN KEY (reversal_movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_reservations\n ADD CONSTRAINT plg_inventory_reservations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_destination_location_id_fkey FOREIGN KEY (destination_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_operation_fk FOREIGN KEY (operation_id) REFERENCES public.plg_inventory_operations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_reverses_movement_id_fkey FOREIGN KEY (reverses_movement_id) REFERENCES public.plg_inventory_stock_movements(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_stock_location_id_fkey FOREIGN KEY (source_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_supplier_id_fkey FOREIGN KEY (supplier_id) REFERENCES public.people(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_measurement_unit_id_fkey FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_stock_location_id_fkey FOREIGN KEY (stock_location_id) REFERENCES public.plg_inventory_stock_locations(id);\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_from_unit_id_fkey FOREIGN KEY (from_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_owner_fk FOREIGN KEY (tenant_id, owner_id) REFERENCES app.memberships(tenant_id, user_id) ON DELETE SET NULL;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_to_unit_id_fkey FOREIGN KEY (to_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE;\n\nALTER TABLE ONLY public.plg_inventory_unit_conversions\n ADD CONSTRAINT plg_inventory_unit_conversions_unit_fk FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;\n\n\n-- \u2500\u2500 trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE TRIGGER fulfillments_emit_completed AFTER INSERT OR UPDATE OF status ON public.fulfillments FOR EACH ROW EXECUTE FUNCTION public.trg_fulfillments_emit();\n\nCREATE TRIGGER plg_inventory_count_items_updated_at BEFORE UPDATE ON public.plg_inventory_count_items FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_count_sessions_updated_at BEFORE UPDATE ON public.plg_inventory_count_sessions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_fulfillment_items_inherit BEFORE INSERT OR UPDATE OF item_id ON public.plg_inventory_fulfillment_items FOR EACH ROW EXECUTE FUNCTION public.trg_plg_inventory_fulfillment_items_inherit();\n\nCREATE TRIGGER plg_inventory_fulfillment_items_updated_at BEFORE UPDATE ON public.plg_inventory_fulfillment_items FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_measurement_units_updated_at BEFORE UPDATE ON public.plg_inventory_measurement_units FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_operations_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_operations FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_operations_updated_at BEFORE UPDATE ON public.plg_inventory_operations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_product_categories_updated_at BEFORE UPDATE ON public.plg_inventory_product_categories FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_product_settings_updated_at BEFORE UPDATE ON public.plg_inventory_product_settings FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_recipes_updated_at BEFORE UPDATE ON public.plg_inventory_recipes FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_reservations_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_reservations_one_per_source BEFORE INSERT OR UPDATE OF status, source_type, source_id, line_no ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION app.inventory_reservation_one_per_source();\n\nCREATE TRIGGER plg_inventory_reservations_updated_at BEFORE UPDATE ON public.plg_inventory_reservations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_locations_before_update BEFORE UPDATE ON public.plg_inventory_stock_locations FOR EACH ROW EXECUTE FUNCTION app.inventory_locations_before_update();\n\nCREATE TRIGGER plg_inventory_stock_locations_updated_at BEFORE UPDATE ON public.plg_inventory_stock_locations FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_movements_after_insert AFTER INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_after_insert();\n\nCREATE TRIGGER plg_inventory_stock_movements_append_only BEFORE DELETE OR UPDATE ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_append_only();\n\nCREATE TRIGGER plg_inventory_stock_movements_before_insert BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_before_insert();\n\nCREATE TRIGGER plg_inventory_stock_movements_internal_only BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_stock_movements_source_required BEFORE INSERT ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION app.inventory_movement_source_required();\n\nCREATE TRIGGER plg_inventory_stock_movements_updated_at BEFORE UPDATE ON public.plg_inventory_stock_movements FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_stock_positions_internal_only BEFORE INSERT OR DELETE OR UPDATE ON public.plg_inventory_stock_positions FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\nCREATE TRIGGER plg_inventory_stock_positions_updated_at BEFORE UPDATE ON public.plg_inventory_stock_positions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER plg_inventory_unit_conversions_updated_at BEFORE UPDATE ON public.plg_inventory_unit_conversions FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();\n\nCREATE TRIGGER tenants_inventory_seed_units AFTER INSERT ON public.tenants FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_seed_units_for_tenant();\n\n\n-- \u2500\u2500 row security \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nALTER TABLE public.plg_inventory_count_items ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_count_sessions ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_fulfillment_items ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_measurement_units ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_operations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_categories ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_details ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_product_settings ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipe_groups ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipe_ingredients ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_recipes ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_reservations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_locations ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_movements ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_stock_positions ENABLE ROW LEVEL SECURITY;\n\nALTER TABLE public.plg_inventory_unit_conversions ENABLE ROW LEVEL SECURITY;\n\n\n-- \u2500\u2500 policy \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE POLICY plg_inventory_count_items_authz_delete ON public.plg_inventory_count_items FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_insert ON public.plg_inventory_count_items FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_select ON public.plg_inventory_count_items FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_items_authz_update ON public.plg_inventory_count_items FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_item'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_item'::text, plg_inventory_count_items.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_items.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_items.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_items.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_delete ON public.plg_inventory_count_sessions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_insert ON public.plg_inventory_count_sessions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_select ON public.plg_inventory_count_sessions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_count_sessions_authz_update ON public.plg_inventory_count_sessions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.count_session'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.count_session'::text, plg_inventory_count_sessions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_count_sessions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_sessions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_count_sessions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_count_sessions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_delete ON public.plg_inventory_fulfillment_items FOR DELETE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_insert ON public.plg_inventory_fulfillment_items FOR INSERT TO authenticated WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_select ON public.plg_inventory_fulfillment_items FOR SELECT TO authenticated USING ((item_id IN ( SELECT i.id\n FROM public.items i)));\n\nCREATE POLICY plg_inventory_fulfillment_items_authz_update ON public.plg_inventory_fulfillment_items FOR UPDATE TO authenticated USING (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission))) WITH CHECK (((item_id IN ( SELECT i.id\n FROM public.items i)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_fulfillment_items.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_delete ON public.plg_inventory_measurement_units FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_insert ON public.plg_inventory_measurement_units FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_select ON public.plg_inventory_measurement_units FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_measurement_units_authz_update ON public.plg_inventory_measurement_units FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.measurement_unit'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.measurement_unit'::text, plg_inventory_measurement_units.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_measurement_units.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_measurement_units.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_measurement_units.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_measurement_units.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_delete ON public.plg_inventory_operations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_insert ON public.plg_inventory_operations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_select ON public.plg_inventory_operations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_operations_authz_update ON public.plg_inventory_operations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.operation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.operation'::text, plg_inventory_operations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_operations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_operations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_operations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_operations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_delete ON public.plg_inventory_product_categories FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_insert ON public.plg_inventory_product_categories FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_select ON public.plg_inventory_product_categories FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_authz_update ON public.plg_inventory_product_categories FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_category'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_category'::text, plg_inventory_product_categories.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_categories.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_categories.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_categories.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_categories.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_categories_delete ON public.plg_inventory_product_categories FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_insert ON public.plg_inventory_product_categories FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_select ON public.plg_inventory_product_categories FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_categories_update ON public.plg_inventory_product_categories FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_delete ON public.plg_inventory_product_details FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_insert ON public.plg_inventory_product_details FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_select ON public.plg_inventory_product_details FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_details_update ON public.plg_inventory_product_details FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_product_settings_authz_delete ON public.plg_inventory_product_settings FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_insert ON public.plg_inventory_product_settings FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_select ON public.plg_inventory_product_settings FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_product_settings_authz_update ON public.plg_inventory_product_settings FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.product_settings'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.product_settings'::text, plg_inventory_product_settings.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_product_settings.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_settings.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_product_settings.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_product_settings.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipe_groups_delete ON public.plg_inventory_recipe_groups FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_insert ON public.plg_inventory_recipe_groups FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_select ON public.plg_inventory_recipe_groups FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_groups_update ON public.plg_inventory_recipe_groups FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_delete ON public.plg_inventory_recipe_ingredients FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_insert ON public.plg_inventory_recipe_ingredients FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_select ON public.plg_inventory_recipe_ingredients FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipe_ingredients_update ON public.plg_inventory_recipe_ingredients FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_authz_delete ON public.plg_inventory_recipes FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_insert ON public.plg_inventory_recipes FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_select ON public.plg_inventory_recipes FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_authz_update ON public.plg_inventory_recipes FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.recipe'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.recipe'::text, plg_inventory_recipes.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_recipes.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_recipes.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_recipes.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_recipes.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_recipes_delete ON public.plg_inventory_recipes FOR DELETE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_insert ON public.plg_inventory_recipes FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_select ON public.plg_inventory_recipes FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_recipes_update ON public.plg_inventory_recipes FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT public.user_tenant_ids() AS user_tenant_ids)));\n\nCREATE POLICY plg_inventory_reservations_authz_delete ON public.plg_inventory_reservations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_insert ON public.plg_inventory_reservations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_select ON public.plg_inventory_reservations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_reservations_authz_update ON public.plg_inventory_reservations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.reservation'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.reservation'::text, plg_inventory_reservations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_reservations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_reservations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_reservations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_reservations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_delete ON public.plg_inventory_stock_locations FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_insert ON public.plg_inventory_stock_locations FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_select ON public.plg_inventory_stock_locations FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_locations_authz_update ON public.plg_inventory_stock_locations FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_location'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_location'::text, plg_inventory_stock_locations.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_locations.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_locations.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_locations.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_locations.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_delete ON public.plg_inventory_stock_movements FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_insert ON public.plg_inventory_stock_movements FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_select ON public.plg_inventory_stock_movements FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_movements_authz_update ON public.plg_inventory_stock_movements FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_movement'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_movement'::text, plg_inventory_stock_movements.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_movements.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_movements.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_movements.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_movements.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_delete ON public.plg_inventory_stock_positions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_insert ON public.plg_inventory_stock_positions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_select ON public.plg_inventory_stock_positions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_stock_positions_authz_update ON public.plg_inventory_stock_positions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit) AND ((NOT ( SELECT app.owner_scoped('inventory.stock_position'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.stock_position'::text, plg_inventory_stock_positions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_stock_positions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_positions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_stock_positions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_stock_positions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_delete ON public.plg_inventory_unit_conversions FOR DELETE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.delete'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_insert ON public.plg_inventory_unit_conversions FOR INSERT TO authenticated WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.create'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_select ON public.plg_inventory_unit_conversions FOR SELECT TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.read'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\nCREATE POLICY plg_inventory_unit_conversions_authz_update ON public.plg_inventory_unit_conversions FOR UPDATE TO authenticated USING (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ((NOT ( SELECT app.owner_scoped('inventory.unit_conversion'::text) AS owner_scoped)) OR (owner_id IS NULL) OR (owner_id = ( SELECT auth.uid() AS uid)) OR ( SELECT app.has_grant('inventory.unit_conversion'::text, plg_inventory_unit_conversions.id, ARRAY['shared_with'::text]) AS has_grant) OR ( SELECT app.has_permission('inventory.read_all'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_unit_conversions.unit_id) AS has_permission))) WITH CHECK (((tenant_id = ( SELECT app.current_tenant_id() AS current_tenant_id)) AND ((unit_id IS NULL) OR ( SELECT app.has_unit(plg_inventory_unit_conversions.unit_id) AS has_unit)) AND ( SELECT app.has_permission('inventory.edit'::text, plg_inventory_unit_conversions.unit_id) AS has_permission)));\n\n\n-- \u2500\u2500 acl \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nREVOKE ALL ON FUNCTION app.inventory_apply_to_position(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date, p_effect numeric, p_unit_cost numeric, p_measurement_unit uuid, OUT o_quantity numeric, OUT o_unit_cost numeric) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_authorize_location(p_tenant uuid, p_location uuid, p_perm text) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_available(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_begin_operation(p_tenant uuid, p_kind text, p_key text, p_request jsonb, OUT op_id uuid, OUT existing jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_check_lines(p_lines jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_consume_order_item(p_tenant uuid, p_order uuid, p_order_item uuid, p_unit uuid, p_product uuid, p_quantity numeric, p_actor uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_finish_operation(p_tenant uuid, p_op uuid, p_kind text, p_key text, p_result jsonb, p_audit jsonb) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_movement_json(p_id uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_parse_line(p_tenant uuid, p_line jsonb, p_mode text, OUT product_id uuid, OUT quantity numeric, OUT unit_cost numeric, OUT batch_number text, OUT expiration_date date, OUT measurement_unit_id uuid, OUT declared jsonb, OUT notes text) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_position_json(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_product_label(p_product uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION app.inventory_product_label(p_product uuid) TO authenticated;\nGRANT ALL ON FUNCTION app.inventory_product_label(p_product uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION app.inventory_require_tenant() FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_reservation_json(p_id uuid) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_reservation_one_per_source() FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) FROM PUBLIC;\n\nREVOKE ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.agent_inventory_upsert_recipe(p_tenant_id uuid, p_actor_user_id uuid, p_payload jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.app_product_stock_owner(p_tenant uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.app_product_stock_owner(p_tenant uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_confirm(p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_open_count_session(p_stock_location_id uuid, p_category_id uuid, p_reference text, p_blind boolean, p_notes text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_register_product_image(p_product uuid, p_storage_path text, p_public_url text, p_file_name text, p_file_size integer, p_mime_type text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units() TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units() TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_seed_measurement_units(p_tenant uuid) TO service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_fulfillments_emit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_fulfillments_emit() TO service_role;\n\nREVOKE ALL ON FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() FROM PUBLIC;\nGRANT ALL ON FUNCTION public.trg_plg_inventory_fulfillment_items_inherit() TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_count_items TO anon;\nGRANT ALL ON TABLE public.plg_inventory_count_items TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_count_items TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_count_sessions TO anon;\nGRANT ALL ON TABLE public.plg_inventory_count_sessions TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_count_sessions TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_fulfillment_items TO anon;\nGRANT ALL ON TABLE public.plg_inventory_fulfillment_items TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_fulfillment_items TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_measurement_units TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_measurement_units TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_operations TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_operations TO authenticated;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_product_categories TO anon;\nGRANT ALL ON TABLE public.plg_inventory_product_categories TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_product_categories TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_product_details TO anon;\nGRANT ALL ON TABLE public.plg_inventory_product_details TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_product_details TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_product_settings TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_product_settings TO authenticated;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipe_groups TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipe_groups TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipe_groups TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipe_ingredients TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipe_ingredients TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipe_ingredients TO service_role;\n\nGRANT MAINTAIN ON TABLE public.plg_inventory_recipes TO anon;\nGRANT ALL ON TABLE public.plg_inventory_recipes TO authenticated;\nGRANT ALL ON TABLE public.plg_inventory_recipes TO service_role;\n\nGRANT ALL ON TABLE public.plg_inventory_reservations TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_reservations TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_locations TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_stock_locations TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_movements TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_stock_movements TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_stock_positions TO service_role;\nGRANT SELECT ON TABLE public.plg_inventory_stock_positions TO authenticated;\n\nGRANT ALL ON TABLE public.plg_inventory_unit_conversions TO service_role;\nGRANT SELECT,INSERT,DELETE,UPDATE ON TABLE public.plg_inventory_unit_conversions TO authenticated;\n\nGRANT ALL ON TABLE public.v_inventory_balances TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_balances TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_low_stock TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_low_stock TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_movements TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_movements TO service_role;\n\nGRANT ALL ON TABLE public.v_inventory_product_totals TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_product_totals TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_inventory_products TO anon;\nGRANT ALL ON TABLE public.v_inventory_products TO authenticated;\nGRANT ALL ON TABLE public.v_inventory_products TO service_role;\n\nGRANT MAINTAIN ON TABLE public.v_stock_movements TO anon;\nGRANT ALL ON TABLE public.v_stock_movements TO authenticated;\nGRANT ALL ON TABLE public.v_stock_movements TO service_role;\n\n\n-- \u2500\u2500 comment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCOMMENT ON FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid) IS 'Which variant a product-level movement belongs to (#276): the default one, else the first active. NULL when the catalogue has none \u2014 the movement then writes no balance rather than inventing a variant.';\n\nCOMMENT ON FUNCTION app.inventory_sync_archetype_balance(p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric) IS 'Applies a movement''s delta to public.stock_balances at the archetype''s grain (#276). Batch and expiry stay with this plugin; the archetype gets the number.';\n\nCOMMENT ON FUNCTION public.plg_inventory_on_fulfillment_completed(p_event jsonb) IS 'Consumes a completed DELIVERY through the same door a completed sale uses (PRD 05). The quantity is the delivery line''s own, so an order of two that delivers one moves the balance by one \u2014 which a status column on the sold line could never express.';\n\nCOMMENT ON FUNCTION public.plg_inventory_on_order_completed(p_event jsonb) IS 'Consumes a completed order from the durable event log (024), skipping any line a delivery document already covered (PRD 05). Active recipes still expand into ingredients; a product without a recipe still consumes its own stock.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.system_quantity IS 'What the system believed when the session OPENED \u2014 never re-read at close.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.counted_quantity IS 'NULL means nobody counted this line. It is not zero and never emits an adjustment.';\n\nCOMMENT ON COLUMN public.plg_inventory_count_items.movement_id IS 'The adjustment this line already emitted. Set exactly once \u2014 the per-line idempotency key of the close.';\n\nCOMMENT ON TABLE public.plg_inventory_fulfillment_items IS 'What only a delivery line has (PRD 05): batch, expiry, who handed it over and from which stock location. Composite FK on (item_id, parent_kind) \u2014 the database refuses it over an invoice line, which is why this table has no tax column and that one has no batch.';\n\nCOMMENT ON TABLE public.plg_inventory_operations IS 'Idempotency ledger of the inventory RPCs: (tenant, kind, idempotency_key) \u2192 stored result. A replay returns the stored result and has no second effect.';\n\nCOMMENT ON COLUMN public.plg_inventory_product_details.conversion_factor IS 'How many measurement_unit_id fit in one purchase_unit_id. 1 when the product is bought and consumed in the same unit.';\n\nCOMMENT ON COLUMN public.plg_inventory_product_details.purpose IS 'purchase | sale | both | menu. Unconstrained: the vocabulary is per-app labels, not a schema fact.';\n\nCOMMENT ON TABLE public.plg_inventory_product_settings IS 'Per-product inventory settings (min/max quantity, stock unit, default location), tenant-wide (unit_id NULL) or per unit. Replaces products.min_stock.';\n\nCOMMENT ON COLUMN public.plg_inventory_recipes.metadata IS 'What the assistant could not resolve: {unmatchedIngredients:[{name,quantity,notes}], productHint}.';\n\nCOMMENT ON TABLE public.plg_inventory_reservations IS 'Consumption hooks: inventory_reserve \u2192 inventory_confirm (one out movement, exactly once) \u2192 inventory_reverse. Keyed by the source item through idempotency_key.';\n\nCOMMENT ON TABLE public.plg_inventory_stock_movements IS 'Append-only stock ledger (PRD 06). quantity is signed: the effect on the position at (product, source_location, batch, expiry); a transfer or reversed transfer applies -quantity at destination_location. Written only by the inventory_* RPCs.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.stock_location_id IS 'DEPRECATED compat mirror of source_location_id, kept so the applied 004_stock_movement_view stays replay-safe. Dropped at the cut-over.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_movements.movement_type IS 'DEPRECATED compat mirror of kind, kept so the applied 004_stock_movement_view stays replay-safe. Dropped at the cut-over.';\n\nCOMMENT ON TABLE public.plg_inventory_stock_positions IS 'Derived balances per (product, location, batch, expiry). Maintained by the movements writer trigger; never written by app code. Read through v_inventory_balances / v_inventory_product_totals.';\n\nCOMMENT ON COLUMN public.plg_inventory_stock_positions.unit_type IS 'base | purchase | content \u2014 which of the product''s units this quantity is counted in.';\n\nCOMMENT ON TABLE public.plg_inventory_unit_conversions IS 'from_unit \u2192 to_unit \u00D7 factor, tenant-wide (product_id NULL) or product-specific (a 250 mL bottle: bottle \u2192 mL \u00D7 250). Resolved by public.inventory_convert().';\n\nCOMMENT ON VIEW public.v_inventory_balances IS 'Balances per (product, location, batch, expiry) with reserved/available. security_invoker: the caller sees the units she has access to (inventory.read).';\n\nCOMMENT ON VIEW public.v_inventory_low_stock IS 'Products at or below the min quantity of plg_inventory_product_settings (tenant-wide row: unit_id NULL; per-unit rows).';\n\nCOMMENT ON VIEW public.v_inventory_movements IS 'The append-only ledger with names. movement_type is the SDK legacy label (entry/exit/loss/adjustment/transfer); kind is canonical.';\n\nCOMMENT ON VIEW public.v_inventory_product_totals IS 'Balances per product across the units the caller sees. Replaces products.stock as the read model.';\n\nCOMMENT ON VIEW public.v_inventory_products IS 'The catalogue with its balance (027): every products column, plus stock, available, reserved and min_stock DERIVED from the ledger and the product settings. It replaces the products.stock and products.min_stock columns spine 188 dropped \u2014 same names, same shape, one writer.';\n\n\n-- \u2500\u2500 default privileges: restore \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Put back, so the NEXT thing installed inherits what the chain had.\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT MAINTAIN ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role$stmt$;\nEND $dp$;\n\nDO $dp$ BEGIN\n EXECUTE $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEXCEPTION WHEN insufficient_privilege OR undefined_object THEN\n RAISE NOTICE 'baseline: not ours to set \u2014 %', $stmt$--\n-- PostgreSQL database dump complete\n--$stmt$;\nEND $dp$;\n";
2
2
  export declare const MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED = "-- 001_the_usage_that_left_stock_can_be_audited.sql\n--\n-- AUDITORIA DE USO. No V1 o consumo declarado no atendimento fica numa FILA e\n-- s\u00F3 baixa o estoque depois que algu\u00E9m aprova. O V2 n\u00E3o tem essa fila e n\u00E3o\n-- deveria ter: `order.completed` consome a receita no mesmo instante, pelo log\n-- de eventos, e \u00E9 isso que faz o saldo e a venda contarem a mesma hist\u00F3ria.\n--\n-- O QUE A TELA DO V1 REALMENTE ENTREGA n\u00E3o \u00E9 o represamento \u2014 \u00E9 a PERGUNTA:\n-- \"o que o profissional disse ter usado bate com o padr\u00E3o do servi\u00E7o?\". No V1\n-- ela era feita antes da baixa; aqui \u00E9 feita depois. A resposta \u00E9 a mesma, e a\n-- corre\u00E7\u00E3o continua poss\u00EDvel porque o raz\u00E3o \u00E9 append-only: corrigir \u00E9 lan\u00E7ar o\n-- ajuste da diferen\u00E7a, nunca reescrever o que j\u00E1 saiu.\n--\n-- POR QUE UMA VISTA, E N\u00C3O UMA TABELA DE AUDITORIA. Uma fila pr\u00F3pria seria um\n-- segundo lugar guardando a mesma quantidade, e os dois divergem no primeiro\n-- estorno. Tudo de que a auditoria precisa j\u00E1 est\u00E1 escrito:\n--\n-- `plg_inventory_stock_movements` guarda `source_item_type='order_item'` e\n-- `source_item_id` apontando para a linha vendida, com `unit_cost`,\n-- `total_cost` e a localiza\u00E7\u00E3o de onde saiu.\n--\n-- `plg_inventory_recipes` + `_recipe_ingredients` guardam o PADR\u00C3O \u2014 o mesmo\n-- n\u00FAmero que `app.inventory_consume_order_item` usou para calcular a baixa.\n--\n-- `items.assignee_id` guarda QUEM executou (financeiro 019 j\u00E1 provou o elo),\n-- e `orders.party_id` guarda para quem.\n--\n-- A GRANULARIDADE \u00C9 (linha vendida \u00D7 produto), n\u00E3o o movimento. Um consumo se\n-- parte em v\u00E1rios movimentos quando o FIFO atravessa lotes: tr\u00EAs linhas de\n-- raz\u00E3o para um frasco s\u00F3 confundiriam quem confere.\n--\n-- O PADR\u00C3O \u00C9 O DE HOJE. A receita pode ter mudado desde o atendimento, e a\n-- vista mostra a receita vigente \u2014 igual ao V1, que tamb\u00E9m lia o cadastro atual.\n-- Diverg\u00EAncia antiga que sumiu porque a receita mudou \u00E9 ru\u00EDdo, n\u00E3o achado.\n\nCREATE OR REPLACE VIEW public.v_inventory_usage_audit\nWITH (security_invoker = 'true') AS\nWITH consumed AS (\n SELECT m.tenant_id,\n m.unit_id,\n m.source_item_id AS item_id,\n m.product_id,\n -- o raz\u00E3o guarda sa\u00EDda como negativo; quem confere pensa em positivo\n sum(- m.quantity) AS posted_quantity,\n sum(m.total_cost) AS total_cost,\n min(m.created_at) AS used_at,\n min(m.movement_date) AS movement_date,\n (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,\n count(DISTINCT m.source_location_id)::int AS location_count,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,\n (array_agg(m.metadata ->> 'recipe_id' ORDER BY m.created_at, m.id))[1] AS recipe_id\n FROM public.plg_inventory_stock_movements m\n WHERE m.source_item_type = 'order_item'\n AND m.source_item_id IS NOT NULL\n AND m.kind = 'out'\n -- estornado n\u00E3o \u00E9 consumo: devolver ao estoque apaga a linha da fila\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n )\n GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id\n), corrected AS (\n SELECT c.tenant_id,\n c.source_item_id AS item_id,\n c.product_id,\n -- devolver ao estoque \u00E9 ajuste positivo, e reduz o consumido\n sum(- c.quantity) AS correction_delta,\n -- o raz\u00E3o guarda custo sempre positivo; o sinal vem da quantidade\n sum(- sign(c.quantity) * c.total_cost) AS correction_cost,\n max(c.created_at) AS corrected_at,\n (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason\n FROM public.plg_inventory_stock_movements c\n WHERE c.source_item_type = 'usage_audit'\n AND c.source_item_id IS NOT NULL\n GROUP BY c.tenant_id, c.source_item_id, c.product_id\n)\nSELECT c.tenant_id,\n c.unit_id,\n c.item_id,\n c.product_id,\n p.name AS product_name,\n p.sku AS product_sku,\n c.used_at,\n c.movement_date,\n c.posted_quantity::numeric(14,4) AS posted_quantity,\n (c.posted_quantity + coalesce(k.correction_delta, 0))::numeric(14,4) AS net_quantity,\n std.standard_quantity::numeric(14,4) AS standard_quantity,\n CASE WHEN std.standard_quantity IS NULL THEN NULL\n ELSE ((c.posted_quantity + coalesce(k.correction_delta, 0))\n - std.standard_quantity)::numeric(14,4)\n END AS variance,\n u.abbreviation AS unit_abbreviation,\n -- o custo acompanha a conclus\u00E3o da auditoria, n\u00E3o o que foi lan\u00E7ado:\n -- devolver um frasco devolve o custo dele junto\n (c.total_cost + coalesce(k.correction_cost, 0))::numeric(16,4) AS total_cost,\n c.stock_location_id,\n sl.name AS stock_location_name,\n -- mais de uma posi\u00E7\u00E3o quer dizer que o FIFO atravessou lotes\n c.location_count,\n r.id AS recipe_id,\n r.name AS recipe_name,\n i.order_id,\n i.name AS item_name,\n o.reference_number AS order_reference,\n i.assignee_id AS performed_by_id,\n perf.name AS performed_by_name,\n o.party_id AS customer_id,\n cust.name AS customer_name,\n k.corrected_at,\n k.correction_reason,\n (k.item_id IS NOT NULL) AS is_corrected\n FROM consumed c\n LEFT JOIN corrected k\n ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id\n LEFT JOIN public.products p ON p.id = c.product_id\n LEFT JOIN public.plg_inventory_stock_locations sl ON sl.id = c.stock_location_id\n LEFT JOIN public.plg_inventory_measurement_units u ON u.id = c.measurement_unit_id\n -- A linha vendida e seu pedido chegam por LEFT JOIN de prop\u00F3sito: quem l\u00EA o\n -- estoque pode n\u00E3o ler a venda, e nesse caso a linha aparece sem o nome do\n -- profissional em vez de desaparecer da confer\u00EAncia.\n LEFT JOIN public.items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id\n LEFT JOIN public.orders o ON o.id = i.order_id\n LEFT JOIN public.people perf ON perf.id = i.assignee_id\n LEFT JOIN public.people cust ON cust.id = o.party_id\n LEFT JOIN public.plg_inventory_recipes r\n ON r.id = nullif(c.recipe_id, '')::uuid AND r.tenant_id = c.tenant_id\n LEFT JOIN LATERAL (\n SELECT CASE\n WHEN r.id IS NOT NULL AND coalesce(r.yield_quantity, 0) > 0 THEN (\n SELECT round(sum(ri.quantity) * coalesce(i.quantity, 1) / r.yield_quantity, 4)\n FROM public.plg_inventory_recipe_ingredients ri\n WHERE ri.tenant_id = c.tenant_id\n AND ri.recipe_id = r.id\n AND ri.product_id = c.product_id\n )\n -- sem receita o produto vendido \u00E9 o pr\u00F3prio consumido: o padr\u00E3o \u00E9\n -- a quantidade da linha, e divergir a\u00ED seria erro de sistema\n WHEN r.id IS NULL THEN i.quantity\n ELSE NULL\n END AS standard_quantity\n ) std ON true;\n\nCOMMENT ON VIEW public.v_inventory_usage_audit IS\n 'O consumo que j\u00E1 saiu do estoque, por linha vendida e produto, ao lado do padr\u00E3o da receita vigente e da corre\u00E7\u00E3o que a auditoria lan\u00E7ou. N\u00E3o cria fila: o raz\u00E3o j\u00E1 sabia tudo isso (001).';\n\nGRANT SELECT ON public.v_inventory_usage_audit TO authenticated;\nGRANT SELECT ON public.v_inventory_usage_audit TO service_role;\n\n-- \u2500\u2500 corrigir \u00E9 lan\u00E7ar a diferen\u00E7a, n\u00E3o reescrever o passado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- No V1 \"Corrigir\" e \"Aprovar\" s\u00E3o um ato s\u00F3 porque a baixa ainda n\u00E3o tinha\n-- acontecido. Aqui ela aconteceu, ent\u00E3o corrigir para 40 o que saiu como 50\n-- devolve 10 ao saldo \u2014 e o raz\u00E3o fica com as duas linhas, que \u00E9 o que permite\n-- perguntar depois quem corrigiu o qu\u00EA.\n--\n-- Um motivo \u00E9 obrigat\u00F3rio, como no V1. Ajuste de estoque sem motivo \u00E9 o que\n-- transforma uma confer\u00EAncia em um buraco novo.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage(\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_row record;\n v_delta numeric;\n v_unit uuid;\n v_key text;\n v_op record;\n v_id uuid;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n IF p_quantity IS NULL OR p_quantity < 0 THEN\n RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';\n END IF;\n\n -- o consumido de hoje: a baixa original mais o que auditorias j\u00E1 corrigiram\n SELECT sum(- m.quantity) AS net,\n (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,\n (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,\n (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,\n (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost\n INTO v_row\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = v_tenant\n AND m.source_item_id = p_item_id\n AND m.product_id = p_product_id\n AND ((m.source_item_type = 'order_item' AND m.kind = 'out')\n OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n );\n\n IF v_row.net IS NULL THEN\n RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id\n USING ERRCODE = '22023';\n END IF;\n\n -- positivo devolve ao estoque, negativo tira mais\n v_delta := v_row.net - p_quantity;\n IF v_delta = 0 THEN\n RETURN jsonb_build_object('kind', 'usage_audit', 'status', 'unchanged',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'quantity', p_quantity);\n END IF;\n\n IF v_delta < 0\n AND app.inventory_available(v_tenant, p_product_id, v_row.location_id,\n v_row.batch_number, v_row.expiration_date) < - v_delta THEN\n RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',\n p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';\n END IF;\n\n v_unit := app.inventory_authorize_location(v_tenant, v_row.location_id, 'inventory.edit');\n\n -- corrigir duas vezes para o MESMO n\u00FAmero \u00E9 o mesmo ato, e a segunda chamada\n -- devolve o resultado da primeira em vez de mexer no saldo de novo\n v_key := 'usage_audit:' || p_item_id::text || ':' || p_product_id::text || ':' || p_quantity::text;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'quantity', p_quantity, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n reason, source_item_type, source_item_id, idempotency_key, line_no,\n operation_id, metadata)\n VALUES\n (v_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,\n v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',\n v_reason, 'usage_audit', p_item_id, v_key, 1, v_op.op_id,\n jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,\n 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))\n RETURNING id INTO v_id;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'usage_audit',\n 'idempotency_key', v_key, 'status', 'corrected',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'location_id', v_row.location_id, 'unit_id', v_unit,\n 'previous_quantity', v_row.net, 'quantity', p_quantity,\n 'delta', v_delta, 'reason', v_reason,\n 'movements', app.inventory_movement_json(v_id)),\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'unit_id', v_unit,\n 'previous_quantity', v_row.net, 'quantity', p_quantity,\n 'reason', v_reason));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) IS\n 'Corrige o que o atendimento disse ter usado: lan\u00E7a o ajuste da diferen\u00E7a no raz\u00E3o, com motivo, e deixa o rastro ligado \u00E0 linha vendida por source_item_type=usage_audit (001).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO service_role;\n";
3
3
  export declare const MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE = "-- 002_a_auditoria_fecha_em_lote.sql\n--\n-- A AUDITORIA VIRA UMA CONTAGEM. Corrigir linha a linha, cada uma com o seu\n-- motivo, \u00E9 o desenho de quem conserta UM engano. Quem confere um dia inteiro\n-- desce a lista digitando o que realmente saiu e explica o conjunto UMA vez \u2014\n-- \u00E9 o mesmo gesto do fechamento de contagem (`inventory_close_count_session`),\n-- que emite N ajustes sob um motivo s\u00F3.\n--\n-- POR QUE ISSO PRECISA DE UMA RPC NOVA, E N\u00C3O DE UM LA\u00C7O NO CLIENTE. Um la\u00E7o\n-- de N chamadas produz N opera\u00E7\u00F5es, N motivos iguais repetidos e N transa\u00E7\u00F5es:\n-- a terceira pode falhar depois de as duas primeiras terem gravado, e o\n-- operador fica sem saber o que aplicou. Aqui o lote \u00E9 UM\n-- `plg_inventory_operations` \u2014 um evento, com as linhas dentro \u2014 e o\n-- `operation_id` de cada movimento \u00E9 o que devolve o lote inteiro depois.\n--\n-- O QUE **N\u00C3O** MUDA: continua sendo o raz\u00E3o que manda. Aplicar o lote LAN\u00C7A a\n-- diferen\u00E7a de cada linha como ajuste; nada \u00E9 reescrito, nada vira UPDATE de\n-- saldo, e uma linha cuja quantidade n\u00E3o mudou n\u00E3o gera movimento nenhum. Por\n-- isso o rascunho pode viver na tela: n\u00E3o existe segundo lugar guardando a\n-- mesma quantidade, que \u00E9 o mesmo motivo pelo qual a 001 recusou uma fila.\n\n-- \u2500\u2500 uma linha do lote, sem abrir opera\u00E7\u00E3o pr\u00F3pria \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Extra\u00EDdo de `inventory_correct_usage` (001) para que o ato de uma linha e o\n-- de um lote sejam literalmente o mesmo c\u00F3digo. N\u00E3o abre nem fecha opera\u00E7\u00E3o:\n-- quem chama j\u00E1 \u00E9 dono de uma.\nCREATE OR REPLACE FUNCTION app.inventory_correct_usage_line(\n p_tenant uuid,\n p_op uuid,\n p_key text,\n p_line_no integer,\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_row record;\n v_delta numeric;\n v_unit uuid;\n v_id uuid;\nBEGIN\n IF p_quantity IS NULL OR p_quantity < 0 THEN\n RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';\n END IF;\n\n -- o consumido de hoje: a baixa original mais o que auditorias j\u00E1 corrigiram\n SELECT sum(- m.quantity) AS net,\n (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,\n (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,\n (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,\n (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,\n (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost\n INTO v_row\n FROM public.plg_inventory_stock_movements m\n WHERE m.tenant_id = p_tenant\n AND m.source_item_id = p_item_id\n AND m.product_id = p_product_id\n AND ((m.source_item_type = 'order_item' AND m.kind = 'out')\n OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))\n AND NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_movements r\n WHERE r.reverses_movement_id = m.id\n );\n\n IF v_row.net IS NULL THEN\n RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id\n USING ERRCODE = '22023';\n END IF;\n\n -- positivo devolve ao estoque, negativo tira mais\n v_delta := v_row.net - p_quantity;\n IF v_delta = 0 THEN\n RETURN jsonb_build_object('status', 'unchanged', 'item_id', p_item_id,\n 'product_id', p_product_id, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', 0);\n END IF;\n\n IF v_delta < 0\n AND app.inventory_available(p_tenant, p_product_id, v_row.location_id,\n v_row.batch_number, v_row.expiration_date) < - v_delta THEN\n RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',\n p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';\n END IF;\n\n v_unit := app.inventory_authorize_location(p_tenant, v_row.location_id, 'inventory.edit');\n\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,\n batch_number, expiration_date, measurement_unit_id, document_type,\n reason, source_item_type, source_item_id, idempotency_key, line_no,\n operation_id, metadata)\n VALUES\n (p_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,\n v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',\n p_reason, 'usage_audit', p_item_id, p_key, p_line_no, p_op,\n jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,\n 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))\n RETURNING id INTO v_id;\n\n RETURN jsonb_build_object('status', 'corrected', 'item_id', p_item_id,\n 'product_id', p_product_id, 'location_id', v_row.location_id,\n 'unit_id', v_unit, 'previous_quantity', v_row.net,\n 'quantity', p_quantity, 'delta', v_delta,\n 'movements', app.inventory_movement_json(v_id));\nEND $$;\n\nCOMMENT ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) IS\n 'Uma linha de corre\u00E7\u00E3o de consumo dentro de uma opera\u00E7\u00E3o j\u00E1 aberta: lan\u00E7a a diferen\u00E7a no raz\u00E3o. Compartilhada pela corre\u00E7\u00E3o avulsa e pelo lote (002).';\n\nREVOKE ALL ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) FROM PUBLIC;\n\n-- \u2500\u2500 a corre\u00E7\u00E3o avulsa passa a ser um lote de uma linha \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Mesma assinatura, mesma chave de idempot\u00EAncia, mesmo retorno: nada que j\u00E1\n-- chamava esta fun\u00E7\u00E3o precisa saber que o miolo mudou de casa.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage(\n p_item_id uuid,\n p_product_id uuid,\n p_quantity numeric,\n p_reason text\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_key text;\n v_op record;\n v_line jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n\n -- corrigir duas vezes para o MESMO n\u00FAmero \u00E9 o mesmo ato, e a segunda chamada\n -- devolve o resultado da primeira em vez de mexer no saldo de novo\n v_key := 'usage_audit:' || p_item_id::text || ':' || p_product_id::text || ':' || p_quantity::text;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'quantity', p_quantity, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n v_line := app.inventory_correct_usage_line(\n v_tenant, v_op.op_id, v_key, 1, p_item_id, p_product_id, p_quantity, v_reason);\n\n IF v_line->>'status' = 'unchanged' THEN\n RETURN jsonb_build_object('kind', 'usage_audit', 'status', 'unchanged',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'quantity', p_quantity);\n END IF;\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key,\n jsonb_build_object('operation_id', v_op.op_id, 'kind', 'usage_audit',\n 'idempotency_key', v_key, 'status', 'corrected',\n 'item_id', p_item_id, 'product_id', p_product_id,\n 'location_id', v_line->'location_id', 'unit_id', v_line->'unit_id',\n 'previous_quantity', v_line->'previous_quantity',\n 'quantity', p_quantity, 'delta', v_line->'delta', 'reason', v_reason,\n 'movements', v_line->'movements'),\n jsonb_build_object('source', 'usage_audit', 'item_id', p_item_id,\n 'product_id', p_product_id, 'unit_id', v_line->'unit_id',\n 'previous_quantity', v_line->'previous_quantity',\n 'quantity', p_quantity, 'reason', v_reason));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) IS\n 'Corrige o que o atendimento disse ter usado: lan\u00E7a o ajuste da diferen\u00E7a no raz\u00E3o, com motivo, e deixa o rastro ligado \u00E0 linha vendida por source_item_type=usage_audit (001, reescrita sobre o miolo compartilhado em 002).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage(uuid, uuid, numeric, text) TO service_role;\n\n-- \u2500\u2500 o lote: um motivo, N linhas, UM evento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- `p_lines` \u00E9 `[{\"item_id\": uuid, \"product_id\": uuid, \"quantity\": numeric}, \u2026]`\n-- \u2014 a linha vendida, o produto e o que a confer\u00EAncia diz que realmente saiu.\n--\n-- A chave de idempot\u00EAncia sai do CONTE\u00DADO do lote (linhas ordenadas + motivo),\n-- n\u00E3o de um rel\u00F3gio: reenviar o mesmo lote \u2014 o duplo clique, a rede que caiu\n-- entre gravar e responder \u2014 devolve o resultado do primeiro envio. E mesmo\n-- sem isso o dano seria zero: a segunda passagem calcula delta 0 e n\u00E3o emite.\nCREATE OR REPLACE FUNCTION public.inventory_correct_usage_batch(\n p_reason text,\n p_lines jsonb\n) RETURNS jsonb\n LANGUAGE plpgsql\n SECURITY DEFINER\n SET search_path TO ''\nAS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_reason text := nullif(btrim(p_reason), '');\n v_key text;\n v_op record;\n v_line jsonb;\n v_result jsonb;\n r record;\n v_no integer := 0;\n v_applied integer := 0;\n v_unchanged integer := 0;\n v_lines jsonb := '[]'::jsonb;\n v_movements jsonb := '[]'::jsonb;\nBEGIN\n IF v_reason IS NULL THEN\n RAISE EXCEPTION 'inventory: a usage correction needs a reason' USING ERRCODE = '22023';\n END IF;\n PERFORM app.inventory_check_lines(p_lines);\n\n SELECT 'usage_audit_batch:' || md5(v_reason || '|' || string_agg(\n (l->>'item_id') || ':' || (l->>'product_id') || ':' || (l->>'quantity'), ',' ORDER BY\n (l->>'item_id'), (l->>'product_id')))\n INTO v_key\n FROM jsonb_array_elements(p_lines) AS l;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('source', 'usage_audit_batch', 'reason', v_reason, 'lines', p_lines));\n IF v_op.existing IS NOT NULL THEN\n RETURN v_op.existing;\n END IF;\n\n -- a ordem do array \u00E9 a ordem em que a pessoa digitou, e \u00E9 ela que o\n -- `line_no` guarda: reler o lote depois devolve a confer\u00EAncia como ela foi\n -- feita, n\u00E3o reordenada por id\n FOR r IN\n SELECT (l->>'item_id')::uuid AS item_id,\n (l->>'product_id')::uuid AS product_id,\n (l->>'quantity')::numeric AS quantity,\n ord\n FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)\n ORDER BY ord\n LOOP\n IF r.item_id IS NULL OR r.product_id IS NULL OR r.quantity IS NULL THEN\n RAISE EXCEPTION 'inventory: every audit line needs item_id, product_id and quantity'\n USING ERRCODE = '22023';\n END IF;\n v_no := v_no + 1;\n v_line := app.inventory_correct_usage_line(\n v_tenant, v_op.op_id, v_key, v_no, r.item_id, r.product_id, r.quantity, v_reason);\n IF v_line->>'status' = 'unchanged' THEN\n v_unchanged := v_unchanged + 1;\n ELSE\n v_applied := v_applied + 1;\n v_movements := v_movements || coalesce(v_line->'movements', '[]'::jsonb);\n END IF;\n v_lines := v_lines || jsonb_build_array(v_line - 'movements');\n END LOOP;\n\n v_result := jsonb_build_object(\n 'operation_id', v_op.op_id, 'kind', 'usage_audit_batch',\n 'idempotency_key', v_key, 'status', 'corrected', 'reason', v_reason,\n 'lines_applied', v_applied, 'lines_unchanged', v_unchanged,\n 'lines', v_lines, 'movements', v_movements);\n\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'adjust', v_key, v_result,\n jsonb_build_object('source', 'usage_audit_batch', 'reason', v_reason,\n 'lines_applied', v_applied, 'lines_unchanged', v_unchanged));\nEND $$;\n\nCOMMENT ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) IS\n 'Fecha uma confer\u00EAncia de consumo inteira: N linhas corrigidas sob UM motivo e UMA opera\u00E7\u00E3o, cada diferen\u00E7a lan\u00E7ada como ajuste no raz\u00E3o. O operation_id devolve o lote depois (002).';\n\nREVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PUBLIC;\nGRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;\nGRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;\n";
4
+ export declare const MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = "-- 003_stock_count_closes_through_ledger.sql\n--\n-- A physical count is a reconciliation, not a second stock writer. The old\n-- function inserted values into GENERATED columns, recorded the counted total\n-- instead of the variance and then updated the position a second time outside\n-- the ledger trigger. This replacement emits one canonical `adjust` movement\n-- per divergent line and lets the existing movement trigger update the balance.\n--\n-- Safety gates: every line must be counted (zero is valid); any stock movement\n-- after the opening snapshot aborts the close; lot stock requires a future\n-- batch-aware count instead of guessing which lot changed.\n\nCREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb\n LANGUAGE plpgsql SECURITY DEFINER\n SET search_path TO ''\n AS $$\nDECLARE\n v_tenant uuid := app.inventory_require_tenant();\n v_session public.plg_inventory_count_sessions%ROWTYPE;\n v_item public.plg_inventory_count_items%ROWTYPE;\n v_op record;\n v_delta numeric;\n v_current numeric;\n v_current_cost numeric;\n v_has_lots boolean;\n v_movement_id uuid;\n v_reason text;\n v_key text := 'count:' || p_session_id::text || ':close:v1';\n v_line_no int := 0;\n v_uncounted int;\n v_conflicts int;\n v_emitted int := 0;\n v_movements jsonb := '[]'::jsonb;\n v_positions jsonb := '[]'::jsonb;\n v_result jsonb;\nBEGIN\n SELECT * INTO v_session FROM public.plg_inventory_count_sessions\n WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;\n IF NOT FOUND THEN\n RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');\n END IF;\n PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');\n\n IF v_session.status = 'closed' THEN\n RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,\n 'status', 'closed', 'already_closed', true, 'adjustments_created', 0);\n END IF;\n IF v_session.status = 'cancelled' THEN\n RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');\n END IF;\n\n v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');\n\n SELECT count(*) INTO v_uncounted\n FROM public.plg_inventory_count_items i\n WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;\n IF v_uncounted > 0 THEN\n RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted\n USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';\n END IF;\n\n PERFORM p.id\n FROM public.plg_inventory_stock_positions p\n JOIN public.plg_inventory_count_items i\n ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id\n WHERE i.session_id = p_session_id\n AND p.stock_location_id = v_session.stock_location_id\n ORDER BY p.id\n FOR UPDATE OF p;\n\n SELECT count(DISTINCT i.id) INTO v_conflicts\n FROM public.plg_inventory_count_items i\n JOIN public.plg_inventory_stock_movements m\n ON m.tenant_id = i.tenant_id\n AND m.product_id = i.product_id\n AND (m.source_location_id = v_session.stock_location_id\n OR m.destination_location_id = v_session.stock_location_id)\n WHERE i.session_id = p_session_id\n AND m.created_at > v_session.opened_at\n AND (i.movement_id IS NULL OR m.id <> i.movement_id);\n IF v_conflicts > 0 THEN\n RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts\n USING ERRCODE = '40001';\n END IF;\n\n SELECT * INTO v_op FROM app.inventory_begin_operation(\n v_tenant, 'adjust', v_key,\n jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));\n IF v_op.existing IS NOT NULL THEN\n UPDATE public.plg_inventory_count_sessions\n SET status = 'closed', closed_at = coalesce(closed_at, now()),\n closed_by = coalesce(closed_by, auth.uid()), updated_at = now()\n WHERE id = p_session_id;\n RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);\n END IF;\n\n FOR v_item IN\n SELECT * FROM public.plg_inventory_count_items\n WHERE session_id = p_session_id AND movement_id IS NULL\n ORDER BY id\n FOR UPDATE\n LOOP\n SELECT coalesce(sum(p.quantity), 0),\n coalesce(max(p.unit_cost), v_item.unit_cost, 0),\n coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)\n INTO v_current, v_current_cost, v_has_lots\n FROM public.plg_inventory_stock_positions p\n WHERE p.tenant_id = v_tenant\n AND p.product_id = v_item.product_id\n AND p.stock_location_id = v_session.stock_location_id;\n\n v_delta := v_item.counted_quantity - v_current;\n IF v_delta = 0 THEN CONTINUE; END IF;\n IF v_has_lots THEN\n RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id\n USING ERRCODE = '22023';\n END IF;\n\n v_line_no := v_line_no + 1;\n INSERT INTO public.plg_inventory_stock_movements (\n tenant_id, product_id, kind, quantity, unit_cost,\n source_location_id, document_type, reason,\n idempotency_key, line_no, operation_id,\n source_item_type, source_item_id, metadata\n ) VALUES (\n v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,\n v_session.stock_location_id, 'stock_count', v_reason,\n v_key, v_line_no, v_op.op_id,\n 'stock_count_item', v_item.id,\n jsonb_build_object(\n 'countSessionId', p_session_id,\n 'countItemId', v_item.id,\n 'systemQuantity', v_item.system_quantity,\n 'currentQuantity', v_current,\n 'countedQuantity', v_item.counted_quantity,\n 'variance', v_delta\n )\n )\n RETURNING id INTO v_movement_id;\n\n UPDATE public.plg_inventory_count_items\n SET movement_id = v_movement_id, updated_at = now()\n WHERE id = v_item.id;\n v_movements := v_movements || app.inventory_movement_json(v_movement_id);\n v_positions := v_positions || app.inventory_position_json(\n v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);\n v_emitted := v_emitted + 1;\n END LOOP;\n\n UPDATE public.plg_inventory_count_sessions\n SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()\n WHERE id = p_session_id;\n\n v_result := jsonb_build_object(\n 'ok', true, 'session_id', p_session_id, 'status', 'closed',\n 'already_closed', false, 'adjustments_created', v_emitted,\n 'operation_id', v_op.op_id, 'idempotency_key', v_key,\n 'movements', v_movements, 'positions', v_positions);\n RETURN app.inventory_finish_operation(\n v_tenant, v_op.op_id, 'count.close', v_key, v_result,\n jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));\nEND;\n$$;\n";
4
5
  export declare const MIGRATIONS: Array<{
5
6
  id: string;
6
7
  sql: string;