@fayz-ai/plugin-inventory 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/data/mock.d.ts.map +1 -1
  2. package/dist/data/supabase.d.ts.map +1 -1
  3. package/dist/data/tables.d.ts +2 -0
  4. package/dist/data/tables.d.ts.map +1 -1
  5. package/dist/data/types.d.ts +30 -1
  6. package/dist/data/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +5024 -3706
  10. package/dist/index.js.map +1 -1
  11. package/dist/lib/movement-vocabulary.d.ts.map +1 -1
  12. package/dist/lib/onboarding.d.ts +11 -0
  13. package/dist/lib/onboarding.d.ts.map +1 -0
  14. package/dist/lib/setup-guides.d.ts +3 -0
  15. package/dist/lib/setup-guides.d.ts.map +1 -0
  16. package/dist/lib/stock-count.d.ts +4 -1
  17. package/dist/lib/stock-count.d.ts.map +1 -1
  18. package/dist/locales/en.d.ts.map +1 -1
  19. package/dist/locales/pt-BR.d.ts.map +1 -1
  20. package/dist/migrations/index.d.ts +3 -25
  21. package/dist/migrations/index.d.ts.map +1 -1
  22. package/dist/store.d.ts +16 -1
  23. package/dist/store.d.ts.map +1 -1
  24. package/dist/types.d.ts +87 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/views/InventoryPage.d.ts.map +1 -1
  27. package/dist/views/MovementHistoryView.d.ts.map +1 -1
  28. package/dist/views/ProductCrudForm.d.ts.map +1 -1
  29. package/dist/views/ProductListView.d.ts.map +1 -1
  30. package/dist/views/ProductStockTab.d.ts.map +1 -1
  31. package/dist/views/RecipeDetailView.d.ts.map +1 -1
  32. package/dist/views/RecipesView.d.ts.map +1 -1
  33. package/dist/views/StockCountsView.d.ts.map +1 -1
  34. package/dist/views/StockMovementView.d.ts.map +1 -1
  35. package/dist/views/UsageAuditView.d.ts +3 -0
  36. package/dist/views/UsageAuditView.d.ts.map +1 -0
  37. package/package.json +4 -4
@@ -1,28 +1,6 @@
1
- export declare const MIGRATION_000_PLG_RENAME = "-- 000_plg_rename.sql \u2014 rename legacy inventory tables to plg_inventory_* for pools\n-- provisioned before the industry-pool rename. Guarded: fires only when the legacy\n-- name exists and the target does not, so fresh pools skip every branch.\nDO $$\nBEGIN\n IF to_regclass('public.stock_locations') IS NOT NULL AND to_regclass('public.plg_inventory_stock_locations') IS NULL THEN\n ALTER TABLE public.stock_locations RENAME TO plg_inventory_stock_locations;\n END IF;\n IF to_regclass('public.stock_movements') IS NOT NULL AND to_regclass('public.plg_inventory_stock_movements') IS NULL THEN\n ALTER TABLE public.stock_movements RENAME TO plg_inventory_stock_movements;\n END IF;\n IF to_regclass('public.stock_positions') IS NOT NULL AND to_regclass('public.plg_inventory_stock_positions') IS NULL THEN\n ALTER TABLE public.stock_positions RENAME TO plg_inventory_stock_positions;\n END IF;\n IF to_regclass('public.recipes') IS NOT NULL AND to_regclass('public.plg_inventory_recipes') IS NULL THEN\n ALTER TABLE public.recipes RENAME TO plg_inventory_recipes;\n END IF;\n IF to_regclass('public.recipe_ingredients') IS NOT NULL AND to_regclass('public.plg_inventory_recipe_ingredients') IS NULL THEN\n ALTER TABLE public.recipe_ingredients RENAME TO plg_inventory_recipe_ingredients;\n END IF;\n IF to_regclass('public.measurement_units') IS NOT NULL AND to_regclass('public.plg_inventory_measurement_units') IS NULL THEN\n ALTER TABLE public.measurement_units RENAME TO plg_inventory_measurement_units;\n END IF;\n IF to_regclass('public.product_categories') IS NOT NULL AND to_regclass('public.plg_inventory_product_categories') IS NULL THEN\n ALTER TABLE public.product_categories RENAME TO plg_inventory_product_categories;\n END IF;\nEND $$;\n";
2
- export declare const MIGRATION_001_INVENTORY_BASE = "-- Inventory Plugin: Base Tables\n-- Products use public.products archetype directly\n-- These are plugin-specific extension tables\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_categories (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n parent_id uuid REFERENCES public.plg_inventory_product_categories(id),\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_product_categories ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_product_categories_tenant ON public.plg_inventory_product_categories(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_stock_locations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n description text,\n is_active boolean NOT NULL DEFAULT true,\n unit_id uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_stock_locations ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_locations_tenant ON public.plg_inventory_stock_locations(tenant_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_stock_movements (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id),\n quantity numeric(14,4) NOT NULL,\n movement_type text NOT NULL,\n unit_cost numeric(14,2) DEFAULT 0,\n total_cost numeric(14,2) DEFAULT 0,\n stock_location_id uuid REFERENCES public.plg_inventory_stock_locations(id),\n destination_location_id uuid REFERENCES public.plg_inventory_stock_locations(id),\n supplier_id uuid REFERENCES public.people(id),\n document_number text,\n reason text,\n notes text,\n movement_date date NOT NULL DEFAULT CURRENT_DATE,\n user_id uuid,\n batch_number text,\n expiration_date date,\n metadata jsonb DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_stock_movements ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_movements_tenant ON public.plg_inventory_stock_movements(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_movements_product ON public.plg_inventory_stock_movements(product_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_movements_date ON public.plg_inventory_stock_movements(tenant_id, movement_date);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_stock_positions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id),\n quantity numeric(14,4) NOT NULL,\n unit_cost numeric(14,2) DEFAULT 0,\n stock_location_id uuid REFERENCES public.plg_inventory_stock_locations(id),\n batch_number text,\n expiration_date date,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_stock_positions ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_positions_tenant ON public.plg_inventory_stock_positions(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_stock_positions_product ON public.plg_inventory_stock_positions(product_id);\n";
3
- export declare const MIGRATION_002_RECIPES = "-- Inventory Plugin: Recipes & Technical Specs\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_recipes (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n description text,\n product_id uuid REFERENCES public.products(id),\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 NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_recipes ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipes_tenant ON public.plg_inventory_recipes(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipes_product ON public.plg_inventory_recipes(product_id);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_recipe_ingredients (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n recipe_id uuid NOT NULL REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id),\n quantity numeric(14,4) NOT NULL,\n unit_id uuid,\n display_order integer DEFAULT 0,\n notes text,\n created_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_recipe_ingredients ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipe_ingredients_tenant ON public.plg_inventory_recipe_ingredients(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipe_ingredients_recipe ON public.plg_inventory_recipe_ingredients(recipe_id);\n";
4
- export declare const MIGRATION_003_MEASUREMENT_UNITS = "-- Inventory Plugin: Measurement Units\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_measurement_units (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n name text NOT NULL,\n abbreviation text NOT NULL,\n is_active boolean NOT NULL DEFAULT true,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_measurement_units ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_measurement_units_tenant ON public.plg_inventory_measurement_units(tenant_id);\n";
5
- export declare const MIGRATION_004_STOCK_MOVEMENT_VIEW = "-- ============================================================================\n-- 011_stock_movement_view.sql \u2014 v_stock_movements was reading every tenant.\n--\n-- `plugin-inventory/src/data/supabase.ts` reads `public.v_stock_movements` for\n-- the whole movement history \u2014 it is what the Hist\u00F3rico screen renders. That\n-- view is defined NOWHERE in this repo. It exists on cluster-restaurant-br-01\n-- because a legacy Supabase-CLI chain created it by hand, and it was created\n-- without `security_invoker`.\n--\n-- A view runs with the privileges of its OWNER unless created\n-- `WITH (security_invoker = true)`. The hand-applied view is owned by\n-- `postgres`, who also owns plg_inventory_stock_movements, and a table owner is\n-- exempt from that table's RLS unless the table carries FORCE ROW LEVEL\n-- SECURITY \u2014 which it does not. `authenticated` holds SELECT on the view.\n--\n-- So on a shared industry pool, any signed-in user of ANY tenant could\n--\n-- SELECT * FROM public.v_stock_movements;\n--\n-- and read every tenant's stock ledger \u2014 quantities, unit costs, suppliers.\n--\n-- packages/db/migrations/050_view_invoker.sql exists to close exactly this\n-- class of hole and names nine views. It could not name this one: the view is\n-- in no migration, so nobody auditing the tree knew it existed. This file is\n-- the plugin carrying the fix forward, as 050's header asks each plugin to do.\n--\n-- WHY DROP AND RECREATE RATHER THAN CREATE OR REPLACE. `CREATE OR REPLACE VIEW`\n-- refuses to change a column's name, type or position. The hand-applied\n-- definitions differ across pools \u2014 resto-saas's quarantined\n-- `supabase/migrations.legacy/20260201000018_v_stock_movements_locations.sql`\n-- still selects from `saas_core.products` and the pre-rename\n-- `public.stock_movements`. A replace would fail on whichever pool disagrees.\n-- DROP without CASCADE is deliberate: if something depends on this view we want\n-- to hear about it, not to silently drop it too.\n-- ============================================================================\n\nDROP VIEW IF EXISTS public.v_stock_movements;\n\nCREATE VIEW public.v_stock_movements\nWITH (security_invoker = true) AS\n SELECT\n sm.id,\n sm.tenant_id,\n sm.product_id,\n sm.quantity,\n sm.movement_type,\n sm.unit_cost,\n sm.total_cost,\n sm.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.stock_location_id\n LEFT JOIN public.plg_inventory_stock_locations dl ON dl.id = sm.destination_location_id;\n\nGRANT SELECT ON public.v_stock_movements TO authenticated;\n\nNOTIFY pgrst, 'reload schema';\n\n-- What changes on screen: a Hist\u00F3rico that was quietly listing other tenants'\n-- movements now lists only its own. That is a shorter list and it is the\n-- correct one.\n";
6
- export declare const MIGRATION_005_PRODUCT_DETAILS = "-- ============================================================================\n-- 012_product_details.sql \u2014 the product attributes the plugin kept dropping.\n--\n-- A Product in this plugin is the spine archetype `public.products`. The\n-- provider maps name/sku/price/cost/stock onto real columns and stashes\n-- productType/barcode/brand in `products.metadata` \u2014 but categoryId,\n-- measurementUnitId, supplierId and defaultLocationId were accepted by\n-- CreateProductInput and then silently discarded. Nothing persisted them.\n--\n-- WHY A SATELLITE AND NOT COLUMNS ON public.products.\n--\n-- 1. `products.category_id` already exists and already FKs `public.categories`.\n-- It CANNOT hold a plg_inventory_product_categories id \u2014 the same collision\n-- packages/shop/migrations/0009_products_canonical.sql documents for shop.\n-- The inventory category is a different taxonomy living in a different table.\n--\n-- 2. A spine column must not reference a plugin-prefixed table. `products` is\n-- shared by every plugin and by the archetype layer; uninstall inventory and\n-- a `products.measurement_unit_id` FK dangles. Every FK here points from the\n-- plugin's own table into whatever it likes, which is the direction that\n-- survives.\n--\n-- 3. `ARCHETYPE_COLUMNS.product` in packages/core/src/data/archetype.ts is a\n-- whitelist. splitFields() does not drop unknown keys, it routes them to an\n-- extension table \u2014 so adding a column to products without updating that set\n-- fails the write at runtime rather than at review.\n--\n-- plg_shop_products.product_id -> products.id is the shipped house pattern for\n-- exactly this; this follows it.\n--\n-- ON conversion_factor / purchase_unit_id. Artorius uses neither today\n-- (purchase_unit_id NULL on all 43 rows, conversion_factor 1.0 on all 43), and\n-- a plg_inventory_unit_conversions table would ship empty with no UI able to\n-- fill it. These two columns are the cheap hedge for the one case that does\n-- recur in a kitchen \u2014 buy in boxes, consume in grams \u2014 and cost two columns\n-- instead of a table and a screen.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_details (\n product_id uuid PRIMARY KEY REFERENCES public.products(id) ON DELETE CASCADE,\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n category_id uuid REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL,\n measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL,\n purchase_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL,\n conversion_factor numeric(14,4) NOT NULL DEFAULT 1,\n supplier_id uuid REFERENCES public.people(id) ON DELETE SET NULL,\n default_location_id uuid REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL,\n purpose text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_product_details ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_product_details_tenant ON public.plg_inventory_product_details(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_product_details_category ON public.plg_inventory_product_details(category_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_product_details_supplier ON public.plg_inventory_product_details(supplier_id);\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.';\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\nNOTIFY pgrst, 'reload schema';\n";
7
- export declare const MIGRATION_006_STOCK_POSITION_SLOTS = "-- ============================================================================\n-- 013_stock_position_slots.sql \u2014 make a stock position a slot, not a pile.\n--\n-- A row in plg_inventory_stock_positions is meant to be the on-hand quantity\n-- for one (product, location, batch, expiry) slot. Nothing enforced that. The\n-- provider's createMovement() does a read-modify-write through maybeSingle(),\n-- which means two rows for the same slot make maybeSingle() throw, and a\n-- concurrent write creates the duplicate in the first place.\n--\n-- This is not hypothetical: the Artorius source data carries 46 position rows\n-- across 37 distinct slots \u2014 the same defect, already realised, in the system\n-- we are migrating from.\n--\n-- The unique index is the half that matters. It turns the read-modify-write\n-- into an upsert with an inferrable conflict target.\n--\n-- WHY `NULLS NOT DISTINCT` AND NOT COALESCE EXPRESSIONS. batch_number and\n-- expiration_date are NULL on every row that does not track batches \u2014 which is\n-- most of them \u2014 and under default NULL semantics a plain unique index would\n-- consider each of those rows distinct and dedupe nothing at all. The portable\n-- alternative is an index over COALESCE expressions, but then ON CONFLICT can\n-- only be inferred by repeating those expressions verbatim, which supabase-js's\n-- `onConflict: 'col,col'` cannot express. Requires PostgreSQL 15+; Supabase has\n-- been 15+ since long before this plugin existed.\n--\n-- The fold below runs BEFORE the index because this file must also survive\n-- being applied to a pool that already accumulated duplicates.\n-- ============================================================================\n\nALTER TABLE public.plg_inventory_stock_positions\n ADD COLUMN IF NOT EXISTS measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL,\n ADD COLUMN IF NOT EXISTS unit_type text NOT NULL DEFAULT 'base',\n ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();\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\n-- Fold pre-existing duplicate slots: quantities add, unit_cost becomes the\n-- weighted average of what it replaces. The survivor is picked by lowest id so\n-- that a re-run folds the same way; min(id::text) rather than min(id) because\n-- uuid has no ordering aggregate on every version we target.\nDO $$\nDECLARE folded integer;\nBEGIN\n WITH grouped AS (\n SELECT\n min(id::text)::uuid AS keep_id,\n tenant_id, product_id, stock_location_id, batch_number, expiration_date,\n sum(quantity) AS total_quantity,\n CASE WHEN sum(quantity) <> 0\n THEN sum(quantity * coalesce(unit_cost, 0)) / sum(quantity)\n ELSE max(unit_cost) END AS avg_unit_cost,\n count(*) AS n\n FROM public.plg_inventory_stock_positions\n GROUP BY tenant_id, product_id, stock_location_id, batch_number, expiration_date\n HAVING count(*) > 1\n ), updated AS (\n UPDATE public.plg_inventory_stock_positions p\n SET quantity = g.total_quantity,\n unit_cost = round(g.avg_unit_cost, 2),\n updated_at = now()\n FROM grouped g\n WHERE p.id = g.keep_id\n RETURNING p.id\n )\n DELETE FROM public.plg_inventory_stock_positions p\n USING grouped g\n WHERE p.tenant_id = g.tenant_id\n AND p.product_id = g.product_id\n AND p.stock_location_id IS NOT DISTINCT FROM g.stock_location_id\n AND p.batch_number IS NOT DISTINCT FROM g.batch_number\n AND p.expiration_date IS NOT DISTINCT FROM g.expiration_date\n AND p.id <> g.keep_id;\n\n GET DIAGNOSTICS folded = ROW_COUNT;\n IF folded > 0 THEN\n RAISE NOTICE 'plg_inventory_stock_positions: folded % duplicate row(s) into their slot', folded;\n END IF;\nEND $$;\n\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_inventory_stock_positions_slot\n ON public.plg_inventory_stock_positions\n (tenant_id, product_id, stock_location_id, batch_number, expiration_date)\n NULLS NOT DISTINCT;\n\nNOTIFY pgrst, 'reload schema';\n";
8
- export declare const MIGRATION_007_RECIPE_GROUPS = "-- ============================================================================\n-- 014_recipe_groups.sql \u2014 a recipe's steps, not just its ingredient list.\n--\n-- plg_inventory_recipes + plg_inventory_recipe_ingredients model a flat bill of\n-- materials. A kitchen recipe is staged: \"massa\", \"recheio\", \"montagem\", each\n-- with its own ingredients, order and prep note. Metadata cannot express that \u2014\n-- the group is a shared parent that several ingredient rows point at, and a\n-- jsonb blob per ingredient would duplicate the name and lose the ordering.\n--\n-- group_id is nullable on purpose: an ungrouped ingredient is the common case\n-- and stays valid. ON DELETE SET NULL so deleting a group flattens its\n-- ingredients back into the recipe rather than destroying them.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_recipe_groups (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n recipe_id uuid NOT NULL REFERENCES public.plg_inventory_recipes(id) ON DELETE CASCADE,\n name text NOT NULL,\n preparation_notes text,\n display_order integer NOT NULL DEFAULT 0,\n estimated_minutes integer,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nALTER TABLE public.plg_inventory_recipe_groups ENABLE ROW LEVEL SECURITY;\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipe_groups_tenant ON public.plg_inventory_recipe_groups(tenant_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipe_groups_recipe ON public.plg_inventory_recipe_groups(recipe_id);\n\nALTER TABLE public.plg_inventory_recipe_ingredients\n ADD COLUMN IF NOT EXISTS group_id uuid REFERENCES public.plg_inventory_recipe_groups(id) ON DELETE SET NULL;\n\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_recipe_ingredients_group ON public.plg_inventory_recipe_ingredients(group_id);\n\nNOTIFY pgrst, 'reload schema';\n";
9
- export declare const MIGRATION_008_RLS_POLICIES = "-- ============================================================================\n-- 015_rls_policies.sql \u2014 inventory stops borrowing the spine's sweep.\n--\n-- 001/002/003 ENABLE row-level security and create no policies. The policies\n-- that exist on live pools come from packages/db/migrations/002's auto-sweep,\n-- which discovers every public BASE TABLE carrying a tenant_id. That works, but\n-- only on the second pass: on a first-ever provision the spine step runs before\n-- the plugin step, so at sweep time these tables do not exist yet and come up\n-- RLS-enabled with NO policy \u2014 every insert denied. packages/db/test's replay\n-- pass exists partly to paper over exactly this.\n--\n-- plugin-financial solved it with its own 013_rls_policies.sql. This is the\n-- same file for inventory, and it also covers the two tables added by 005 and\n-- 007, which no sweep has ever seen.\n--\n-- NOTE, so it is not rediscovered as a bug: on pools that already ran the spine\n-- sweep, these tables will now carry TWO permissive policy families \u2014\n-- `tenant_isolation_*` from the sweep and `<table>_*` from here. Permissive\n-- policies OR together and both quals are the identical canonical form, so the\n-- result is unchanged. It is noise in \\d output, not a hole.\n--\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n-- ============================================================================\n\nDO $$\nDECLARE t text;\nBEGIN\n FOR t IN SELECT unnest(ARRAY[\n 'plg_inventory_product_categories','plg_inventory_stock_locations',\n 'plg_inventory_stock_movements','plg_inventory_stock_positions',\n 'plg_inventory_recipes','plg_inventory_recipe_ingredients',\n 'plg_inventory_measurement_units','plg_inventory_product_details',\n 'plg_inventory_recipe_groups'\n ])\n LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\n END LOOP;\nEND $$;\n\n-- plg_inventory_recipe_groups is tenant-scoped like the rest, so the loop above\n-- covers it. plg_inventory_product_details is keyed by product_id but carries\n-- tenant_id for exactly this reason.\n";
10
- export declare const MIGRATION_009_AGENT_RPCS = "-- ============================================================================\n-- plugin-inventory 009: server-plane agent write RPC.\n--\n-- public.agent_inventory_upsert_recipe \u2014 the assistant BUILDS or EDITS a ficha\n-- t\u00E9cnica from any surface or channel (in-app FAB, WhatsApp, MCP). Same\n-- contract as every agent_* RPC (agenda 005, financial 009, forms 003):\n-- (p_tenant_id, p_actor_user_id, p_payload jsonb) \u2192 jsonb\n-- {ok:true, id, record:{...}} | {ok:false, denial:{...}} | {ok:false, error}\n--\n-- Ingredient NAMES are resolved to products.id HERE, so the model can say\n-- \"costela su\u00EDna\" without holding an id. What it names and we cannot match is\n-- not silently dropped and not force-inserted (product_id is NOT NULL on the\n-- ingredient table): it lands in recipes.metadata->'unmatchedIngredients' and\n-- comes back in the result, so the screen can ask the user to pick or create\n-- it. That recovery loop is what made the legacy feature usable.\n--\n-- GRANT authenticated+service_role \u2014 NEVER anon (the broker calls with the pool\n-- service key and injects tenant/actor; in-browser the signed-in user calls it\n-- directly and spine RLS/actor guards bind them to their own tenant).\n-- ============================================================================\n\nALTER TABLE public.plg_inventory_recipes\n ADD COLUMN IF NOT EXISTS metadata jsonb;\n\nCOMMENT ON COLUMN public.plg_inventory_recipes.metadata IS 'What the assistant could not resolve: {unmatchedIngredients:[{name,quantity,notes}], productHint}.';\n\nCREATE OR REPLACE FUNCTION public.agent_inventory_upsert_recipe(\n p_tenant_id uuid,\n p_actor_user_id uuid,\n p_payload jsonb\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\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\nREVOKE ALL ON FUNCTION public.agent_inventory_upsert_recipe(uuid, uuid, jsonb) FROM public;\nREVOKE EXECUTE ON FUNCTION public.agent_inventory_upsert_recipe(uuid, uuid, jsonb) FROM anon;\nGRANT EXECUTE ON FUNCTION public.agent_inventory_upsert_recipe(uuid, uuid, jsonb)\n TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
11
- export declare const MIGRATION_010_STOCK_COUNTS = "-- ============================================================================\n-- 017_stock_counts.sql \u2014 the physical count, as a session.\n--\n-- Until now the only correction this module offered was a per-product\n-- `adjustment` movement with a free-text reason. One product at a time, and no\n-- record of the count that motivated it: the number changed and nobody could\n-- say why, or against what. A dark kitchen closes its month against physical\n-- stock, so the count itself has to be a first-class record.\n--\n-- A session scopes to ONE stock location and optionally to one category, and\n-- lives through open \u2192 counting \u2192 closed (or cancelled).\n--\n-- DESIGN DECISIONS, and why:\n--\n-- 1. SNAPSHOT AT OPEN, NOT AT CLOSE. A count takes an hour, and the system\n-- total moves while it happens \u2014 a delivery arrives, a dish is produced.\n-- Comparing what was counted at 09:20 against what the system believes at\n-- 10:30 produces a variance that describes the clock, not the shelf. So\n-- `system_quantity` is written onto the item row when the session opens,\n-- inside inventory_open_count_session, in one consistent read.\n--\n-- 2. UNCOUNTED IS NOT ZERO. `counted_quantity` is NULLABLE, and NULL is the\n-- whole point: it means nobody looked. The close emits nothing for those\n-- lines. Treating them as zero would wipe the stock of every product the\n-- counter skipped \u2014 the single most destructive thing this feature could do.\n-- \"Counted zero\" is a real, different answer, and it is stored as 0.\n--\n-- 3. BLIND BY DEFAULT. `blind` hides the system quantity while counting, so\n-- the counter writes what is on the shelf instead of confirming what is on\n-- the screen. A count that shows you the answer first is a rubber stamp. It\n-- is a per-session flag because a targeted recount of a known discrepancy is\n-- a legitimate reason to turn it off.\n--\n-- 4. LOCATION IS REQUIRED. The close has to write a stock POSITION, and a\n-- position is (product, location, batch, expiry). A session without a\n-- location would have nowhere unambiguous to put the correction.\n--\n-- 5. THE COUNT IS AT PRODUCT \u00D7 LOCATION, NOT PER BATCH. `system_quantity` sums\n-- every batch slot in the location, and the close applies the variance to\n-- the untracked (batch NULL) slot, leaving batch-tracked slots alone.\n-- Counting per batch would mean asking the counter to read lot codes off\n-- frozen packaging; deliberately out of scope.\n--\n-- 6. THE CLOSE IS ONE TRANSACTION AND IS IDEMPOTENT. It is a SECURITY DEFINER\n-- function so that the movement, the position and products.stock cannot end\n-- up half-written, and re-closing emits nothing: the session row is locked\n-- FOR UPDATE, a session already `closed` returns immediately, and each item\n-- remembers the movement it produced in `movement_id`, so a line that\n-- already adjusted can never adjust twice.\n--\n-- 7. THE ADJUSTMENT'S QUANTITY IS THE COUNTED (ABSOLUTE) FIGURE. The module's\n-- own vocabulary declares `adjustment` as effect `set` \u2014 the quantity is the\n-- new truth, not a delta. The variance is preserved on the item row and in\n-- the movement's metadata, and the position and products.stock move by the\n-- delta.\n--\n-- SECURITY: both functions are SECURITY DEFINER and therefore fence the tenant\n-- themselves \u2014 `tenant_id IN (SELECT public.user_tenant_ids())`, which resolves\n-- from auth.uid(). A service-role caller with no JWT is denied by that fence\n-- rather than being handed a cross-tenant write.\n--\n-- Canonical RLS form: tenant_id IN (SELECT public.user_tenant_ids()).\n-- Idempotent throughout.\n-- ============================================================================\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_count_sessions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n reference text,\n status text NOT NULL DEFAULT 'open',\n stock_location_id uuid NOT NULL REFERENCES public.plg_inventory_stock_locations(id),\n category_id uuid REFERENCES public.plg_inventory_product_categories(id) ON DELETE SET NULL,\n blind boolean NOT NULL DEFAULT true,\n notes text,\n opened_at timestamptz NOT NULL DEFAULT now(),\n opened_by uuid,\n closed_at timestamptz,\n closed_by uuid,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM pg_constraint\n WHERE conname = 'plg_inventory_count_sessions_status_check'\n AND conrelid = 'public.plg_inventory_count_sessions'::regclass\n ) THEN\n ALTER TABLE public.plg_inventory_count_sessions\n ADD CONSTRAINT plg_inventory_count_sessions_status_check\n CHECK (status IN ('open','counting','closed','cancelled'));\n END IF;\nEND $$;\n\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_count_sessions_tenant\n ON public.plg_inventory_count_sessions(tenant_id, opened_at DESC);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_count_sessions_status\n ON public.plg_inventory_count_sessions(tenant_id, status);\n\nCREATE TABLE IF NOT EXISTS public.plg_inventory_count_items (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n session_id uuid NOT NULL REFERENCES public.plg_inventory_count_sessions(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id),\n system_quantity numeric(14,4) NOT NULL DEFAULT 0,\n counted_quantity numeric(14,4),\n unit_cost numeric(14,2) NOT NULL DEFAULT 0,\n notes text,\n counted_at timestamptz,\n counted_by uuid,\n movement_id uuid REFERENCES public.plg_inventory_stock_movements(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\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.';\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.';\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\nCREATE UNIQUE INDEX IF NOT EXISTS uq_plg_inventory_count_items_line\n ON public.plg_inventory_count_items(session_id, product_id);\nCREATE INDEX IF NOT EXISTS idx_plg_inventory_count_items_tenant\n ON public.plg_inventory_count_items(tenant_id);\n\n-- \u2500\u2500 RLS, same shape as 008 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nDO $$\nDECLARE t text;\nBEGIN\n FOR t IN SELECT unnest(ARRAY[\n 'plg_inventory_count_sessions','plg_inventory_count_items'\n ])\n LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t);\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_select') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_select', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_insert') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_insert', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_update') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_update', t);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=t AND policyname=t||'_delete') THEN\n EXECUTE format('CREATE POLICY %I ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t||'_delete', t);\n END IF;\n END LOOP;\nEND $$;\n\n-- \u2500\u2500 open: create the session AND snapshot it, in one read \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.inventory_open_count_session(\n p_stock_location_id uuid,\n p_category_id uuid DEFAULT NULL,\n p_reference text DEFAULT NULL,\n p_blind boolean DEFAULT true,\n p_notes text DEFAULT NULL\n) RETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\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\n-- \u2500\u2500 close: one transaction, one adjustment per divergent counted line \u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.inventory_close_count_session(\n p_session_id uuid,\n p_reason text DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER\nSET search_path = public\nAS $$\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 UPDATE products SET stock = COALESCE(stock, 0) + v_delta, updated_at = now()\n WHERE id = v_item.product_id AND tenant_id = v_session.tenant_id;\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\nREVOKE ALL ON FUNCTION public.inventory_open_count_session(uuid, uuid, text, boolean, text) FROM public;\nREVOKE EXECUTE ON FUNCTION public.inventory_open_count_session(uuid, uuid, text, boolean, text) FROM anon;\nGRANT EXECUTE ON FUNCTION public.inventory_open_count_session(uuid, uuid, text, boolean, text)\n TO authenticated, service_role;\n\nREVOKE ALL ON FUNCTION public.inventory_close_count_session(uuid, text) FROM public;\nREVOKE EXECUTE ON FUNCTION public.inventory_close_count_session(uuid, text) FROM anon;\nGRANT EXECUTE ON FUNCTION public.inventory_close_count_session(uuid, text)\n TO authenticated, service_role;\n\nNOTIFY pgrst, 'reload schema';\n";
12
- export declare const MIGRATION_011_SCAFFOLD = "-- 011_scaffold.sql \u2014 the inventory tables under the ONE RLS template (043\n-- app.scaffold_table), in SHADOW mode (PRD 01 R6 / #135).\n--\n-- Shadow keeps the canonical tenant policies and ADDS the <t>_authz_* template\n-- policies; app.shadow_report() compares both per member; the inventory-base\n-- slice (or an operator, app.scaffold_set_mode) flips to 'enforce' on\n-- evidence \u2014 both calls are idempotent and converge. Guarded per table.\n--\n-- Stock locations become units in V2 (PRD 06); until that slice lands, a\n-- pre-existing unit_id that does not resolve is parked in\n-- app.legacy_unit_quarantine (055) so the composite FK can be added.\n--\n-- NOT scaffolded here: plg_inventory_recipe_ingredients \u2014 its `unit_id` is the\n-- MEASUREMENT unit of the ingredient (002), not a business unit; the template\n-- would read it as app.units and the FK/backfill would destroy the value. The\n-- inventory-base slice renames it (measurement_unit_id) and scaffolds after \u2014\n-- until then it keeps its canonical tenant policies (the doctor scan reports it\n-- as legacy-rls).\nDO $$\nDECLARE\n t record;\n v_n integer;\nBEGIN\n IF to_regprocedure('app.scaffold_table(regclass, text, text, boolean, text)') IS NULL THEN\n RAISE NOTICE 'plugin-inventory 004: app.scaffold_table missing (spine < 043) \u2014 skipped';\n RETURN;\n END IF;\n FOR t IN SELECT * FROM (VALUES\n ('plg_inventory_product_categories', 'inventory.product_category'),\n ('plg_inventory_stock_locations', 'inventory.stock_location'),\n ('plg_inventory_stock_movements', 'inventory.stock_movement'),\n ('plg_inventory_stock_positions', 'inventory.stock_position'),\n ('plg_inventory_recipes', 'inventory.recipe'),\n ('plg_inventory_measurement_units', 'inventory.measurement_unit')\n ) AS v(table_name, resource_type)\n LOOP\n IF to_regclass('public.' || t.table_name) IS NULL THEN CONTINUE; END IF;\n IF to_regprocedure('app.quarantine_legacy_unit_ids(regclass)') IS NOT NULL THEN\n v_n := app.quarantine_legacy_unit_ids(('public.' || t.table_name)::regclass);\n IF v_n > 0 THEN RAISE NOTICE 'plugin-inventory 004: % legacy unit_id value(s) of % parked in app.legacy_unit_quarantine', v_n, t.table_name; END IF;\n END IF;\n PERFORM app.scaffold_table(('public.' || t.table_name)::regclass, t.resource_type, 'inventory', false, 'shadow');\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t.table_name);\n EXECUTE format('GRANT ALL ON public.%I TO service_role', t.table_name);\n END LOOP;\nEND $$;\n";
13
- export declare const MIGRATION_012_INVENTORY_CORE_CONTRACT = "-- ============================================================================\n-- 012_inventory_core_contract.sql \u2014 the inventory base contract (PRD 06 / #143):\n-- locations per unit, positions, append-only movements, product settings,\n-- reservations and the idempotency ledger, all on the authz scaffold (043).\n--\n-- Evolved IN PLACE (no production tenant on these tables): the plg_inventory_*\n-- names of 001 stay, columns are added/renamed behind guards, legacy values are\n-- mapped, and every table is stamped by app.scaffold_table (enforce). Balances\n-- are never written by app code: positions are DERIVED from movements by the\n-- writer trigger of 007 and movements are written only by the RPCs of 008 \u2014\n-- both tables are gated by a session flag the RPCs set (single writer).\n--\n-- products.stock / products.min_stock: the inventory plugin no longer reads or\n-- writes them (balances come from the views of 007; the minimum lives in\n-- plg_inventory_product_settings). The columns are NOT dropped: the shop's\n-- 0009 mirror trigger and backfill INSERT still write products.stock, and the\n-- core archetype allow-list routes `stock`/`min_stock` for host entity defs \u2014\n-- dropping would break both silently. They become one-way MIRRORS maintained\n-- by 007 (documented as deprecated in docs/data/phase-0/06-inventory-base.md).\n--\n-- Idempotent and replay-safe: IF NOT EXISTS / guarded DO blocks throughout.\n-- ============================================================================\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 stock locations (per unit) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- A location belongs to exactly one unit: that is what scopes stock by unit.\n-- Legacy rows without a unit are parked at the tenant's hq unit; if a tenant\n-- has no hq yet the NOT NULL is expressed as a NOT VALID check that a later\n-- replay validates once every row has a unit.\nDO $$\nDECLARE r record;\nBEGIN\n UPDATE public.plg_inventory_stock_locations l\n SET unit_id = u.id\n FROM app.units u\n WHERE l.unit_id IS NULL AND u.tenant_id = l.tenant_id AND u.kind = 'hq';\n\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_locations WHERE unit_id IS NULL) THEN\n ALTER TABLE public.plg_inventory_stock_locations ALTER COLUMN unit_id SET NOT NULL;\n -- the NOT VALID check of an earlier replay is redundant once the column is NOT NULL\n ALTER TABLE public.plg_inventory_stock_locations DROP CONSTRAINT IF EXISTS plg_inventory_stock_locations_unit_required;\n ELSIF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_locations_unit_required'\n AND conrelid = 'public.plg_inventory_stock_locations'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_locations\n ADD CONSTRAINT plg_inventory_stock_locations_unit_required CHECK (unit_id IS NOT NULL) NOT VALID;\n RAISE NOTICE 'plg_inventory_stock_locations: rows without a unit and no hq unit to park them \u2014 unit_id NOT NULL left as NOT VALID check';\n END IF;\nEND $$;\n\nALTER TABLE public.plg_inventory_stock_locations ADD COLUMN IF NOT EXISTS code text;\nALTER TABLE public.plg_inventory_stock_locations ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;\n\nSELECT app.scaffold_table('public.plg_inventory_stock_locations', 'inventory.stock_location', 'inventory', false, 'enforce');\n\n-- One name per unit (case-insensitive) \u2014 only when the pool has no duplicates yet.\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_stock_locations\n GROUP BY tenant_id, unit_id, lower(name) HAVING count(*) > 1\n ) THEN\n CREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_stock_locations_unit_name_key\n ON public.plg_inventory_stock_locations (tenant_id, unit_id, lower(name));\n ELSE\n RAISE NOTICE 'plg_inventory_stock_locations: duplicate names within a unit \u2014 unique index skipped';\n END IF;\nEND $$;\n\n-- The unit of a location with history is frozen: movements carry the unit at the\n-- time they happened and are append-only, so re-parenting a location would leave\n-- its ledger under the old unit. Deactivate and create a new one instead.\nCREATE OR REPLACE FUNCTION app.inventory_locations_before_update()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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-- (trigger created after the movements table below has its final columns)\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 movements (append-only ledger) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Column evolution behind guards: movement_type \u2192 kind (values mapped),\n-- stock_location_id \u2192 source_location_id. document_number keeps main's name.\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'movement_type')\n AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'kind') THEN\n ALTER TABLE public.plg_inventory_stock_movements RENAME COLUMN movement_type TO kind;\n END IF;\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'stock_location_id')\n AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'source_location_id') THEN\n ALTER TABLE public.plg_inventory_stock_movements RENAME COLUMN stock_location_id TO source_location_id;\n END IF;\n -- `document_number` KEEPS main's name. The other two renames earn their churn\n -- (a transfer needs an origin and a destination; a `type` that is really a\n -- kind), this one was only taste \u2014 and taste is not worth a third compat\n -- mirror on a table the applied 004_stock_movement_view still selects.\nEND $$;\n\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS kind text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_location_id uuid REFERENCES public.plg_inventory_stock_locations(id);\n\n-- The three renamed columns come back as GENERATED mirrors, and they are not\n-- decoration. The renames above are semantic \u2014 a transfer has an origin AND a\n-- destination, and `movement_type` was a type that is really a kind \u2014 but\n-- `004_stock_movement_view` is an APPLIED file that selects the old names, and an\n-- applied file is never edited. Without these, the chain applies once and fails\n-- on replay: 004 runs again, the columns it needs are gone, and the run is red\n-- for renames that were right.\n--\n-- Generated and stored, so they cannot drift from what they mirror and cannot be\n-- written to by accident. They go at the cut-over, with the rest of the\n-- compatibility surface.\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT * FROM (VALUES\n ('stock_location_id', 'source_location_id', 'uuid'),\n ('movement_type', 'kind', 'text')\n ) AS v(old_name, new_name, typ) LOOP\n CONTINUE WHEN EXISTS (SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements'\n AND column_name = r.old_name);\n CONTINUE WHEN NOT EXISTS (SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements'\n AND column_name = r.new_name);\n EXECUTE format('ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN %I %s GENERATED ALWAYS AS (%I) STORED',\n r.old_name, r.typ, r.new_name);\n EXECUTE format('COMMENT ON COLUMN public.plg_inventory_stock_movements.%I IS %L',\n r.old_name, format('DEPRECATED compat mirror of %s, kept so the applied 004_stock_movement_view stays replay-safe. Dropped at the cut-over.', r.new_name));\n END LOOP;\nEND $$;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS destination_location_id uuid REFERENCES public.plg_inventory_stock_locations(id);\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS document_type text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS document_number text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS reverses_movement_id uuid REFERENCES public.plg_inventory_stock_movements(id);\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS idempotency_key text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS line_no integer NOT NULL DEFAULT 1;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS operation_id uuid;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_item_type text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_item_id uuid;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id);\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS batch_number text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS expiration_date date;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS reason text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS notes text;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS user_id uuid;\nALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS metadata jsonb DEFAULT '{}'::jsonb;\n-- fractions of a cent per mL are real: widen the money columns (guarded on the\n-- current typmod \u2014 a same-type ALTER would still trip over the views of 013 on replay)\n--\n-- A dependent view makes ALTER TYPE fail outright, and `main` grew one this file\n-- never knew about (`v_stock_movements`, from inventory 004). Rather than name\n-- it \u2014 the next one would fail the same way \u2014 the dependents are captured from\n-- the catalog, dropped, and rebuilt from their own definitions afterwards.\nCREATE TEMP TABLE IF NOT EXISTS _dep_views (nspname text, relname text, def text, owner text);\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN\n SELECT DISTINCT n.nspname, c.relname, pg_get_viewdef(c.oid, true) AS def, pg_get_userbyid(c.relowner) AS owner\n FROM pg_depend d\n JOIN pg_rewrite rw ON rw.oid = d.objid\n JOIN pg_class c ON c.oid = rw.ev_class AND c.relkind IN ('v', 'm')\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE d.refobjid = 'public.plg_inventory_stock_movements'::regclass\n AND d.refobjsubid > 0\n LOOP\n INSERT INTO _dep_views VALUES (r.nspname, r.relname, r.def, r.owner);\n EXECUTE format('DROP VIEW IF EXISTS %I.%I CASCADE', r.nspname, r.relname);\n RAISE NOTICE '012: dependent view %.% dropped for the column widening, rebuilt below', r.nspname, r.relname;\n END LOOP;\nEND $$;\n\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements'\n AND column_name = 'unit_cost' AND (numeric_precision, numeric_scale) IS DISTINCT FROM (16, 4)) THEN\n ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN unit_cost TYPE numeric(16,4);\n END IF;\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements'\n AND column_name = 'total_cost' AND (numeric_precision, numeric_scale) IS DISTINCT FROM (16, 4)) THEN\n ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN total_cost TYPE numeric(16,4);\n END IF;\nEND $$;\nALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN unit_cost SET DEFAULT 0;\nALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN total_cost SET DEFAULT 0;\nUPDATE public.plg_inventory_stock_movements SET unit_cost = 0 WHERE unit_cost IS NULL;\nUPDATE public.plg_inventory_stock_movements SET total_cost = 0 WHERE total_cost IS NULL;\nALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN unit_cost SET NOT NULL;\nALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN total_cost SET NOT NULL;\n\n-- \u2026and the dependents come back exactly as they were.\nDO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT * FROM _dep_views LOOP\n EXECUTE format('CREATE OR REPLACE VIEW %I.%I AS %s', r.nspname, r.relname, r.def);\n EXECUTE format('ALTER VIEW %I.%I OWNER TO %I', r.nspname, r.relname, r.owner);\n -- \u2026and it comes back INVOKER. main's 043_view_invoker swept the core views\n -- and could not reach a plugin's, so v_stock_movements was still answering\n -- with the owner's rights over a tenant_id table \u2014 the same hole, one tree\n -- further out. 900_verification/001 N16 is what caught it.\n EXECUTE format('ALTER VIEW %I.%I SET (security_invoker = true)', r.nspname, r.relname);\n END LOOP;\n DELETE FROM _dep_views;\nEND $$;\n\n-- Legacy kind values (SDK enum and V1 labels) converge onto the five kinds.\n-- Quantity is SIGNED = the effect on the position at (product, source_location,\n-- batch, expiry); the destination (transfer / reversed transfer) gets \u2212quantity.\nUPDATE public.plg_inventory_stock_movements SET kind = CASE lower(coalesce(kind, ''))\n WHEN 'in' THEN 'in' WHEN 'entry' THEN 'in' WHEN 'entrada' THEN 'in' WHEN 'receipt' THEN 'in' WHEN 'purchase' THEN 'in'\n WHEN 'out' THEN 'out' WHEN 'exit' THEN 'out' WHEN 'saida' THEN 'out' WHEN 'sa\u00EDda' THEN 'out' WHEN 'loss' THEN 'out' WHEN 'perda' THEN 'out' WHEN 'sale' THEN 'out' WHEN 'consumption' THEN 'out'\n WHEN 'transfer' THEN 'transfer' WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\u00EAncia' THEN 'transfer'\n WHEN 'reverse' THEN 'reverse' WHEN 'estorno' THEN 'reverse' WHEN 'reversal' THEN 'reverse'\n ELSE 'adjust' END,\n quantity = CASE\n WHEN lower(coalesce(kind, '')) IN ('out', 'exit', 'saida', 'sa\u00EDda', 'loss', 'perda', 'sale', 'consumption', 'transfer', 'transferencia', 'transfer\u00EAncia') AND quantity > 0 THEN -quantity\n ELSE quantity END\n WHERE kind IS NULL OR kind NOT IN ('in', 'out', 'transfer', 'adjust', 'reverse');\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_kind_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_kind_check CHECK (kind IN ('in', 'out', 'transfer', 'adjust', 'reverse'));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_quantity_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_quantity_check CHECK (quantity <> 0);\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_sign_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_sign_check CHECK (\n (kind = 'in' AND quantity > 0) OR (kind = 'out' AND quantity < 0) OR (kind = 'transfer' AND quantity < 0)\n OR kind IN ('adjust', 'reverse'));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_transfer_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_transfer_check CHECK (\n (kind = 'transfer' AND destination_location_id IS NOT NULL AND destination_location_id <> source_location_id)\n OR (kind IN ('in', 'out', 'adjust') AND destination_location_id IS NULL)\n OR kind = 'reverse');\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_reverse_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_reverse_check CHECK (\n (kind = 'reverse') = (reverses_movement_id IS NOT NULL));\n END IF;\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_cost_check' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_movements\n ADD CONSTRAINT plg_inventory_stock_movements_cost_check CHECK (unit_cost >= 0 AND total_cost >= 0);\n END IF;\nEND $$;\n\n-- a movement without a source location has no position to move.\n--\n-- WHY THIS IS AN INSERT RULE AND NOT A `NOT VALID` CHECK. It was one, and the\n-- shape does not hold: NOT VALID skips the rows already there at ADD time, but a\n-- CHECK is re-evaluated on every UPDATE of a row, so the legacy rows it spared\n-- become frozen \u2014 they can never be written again for any reason. That collides\n-- with this same chain two files later, where 018 has to fill `unit_id` on\n-- exactly those rows, and resto's pool proved it: 51 movements from before\n-- locations were required, none of them updatable.\n--\n-- \"Required from here on\" is a rule about INSERTs, so it is enforced where it\n-- belongs. New movements must name a source; the ones that predate the rule stay\n-- readable, stay updatable for backfills, and are never silently corrected.\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements WHERE source_location_id IS NULL) THEN\n ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN source_location_id SET NOT NULL;\n END IF;\n -- The old shape is retired wherever it landed, including on a pool that took\n -- an earlier cut of this file.\n ALTER TABLE public.plg_inventory_stock_movements\n DROP CONSTRAINT IF EXISTS plg_inventory_stock_movements_source_required;\nEND $$;\n\nCREATE OR REPLACE FUNCTION app.inventory_movement_source_required()\nRETURNS trigger LANGUAGE plpgsql 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\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_source_required ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_source_required\n BEFORE INSERT ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_movement_source_required();\n\nSELECT app.scaffold_table('public.plg_inventory_stock_movements', 'inventory.stock_movement', 'inventory', false, 'enforce');\n\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_source_idx ON public.plg_inventory_stock_movements (tenant_id, source_location_id, product_id);\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_dest_idx ON public.plg_inventory_stock_movements (tenant_id, destination_location_id, product_id) WHERE destination_location_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_source_item_idx ON public.plg_inventory_stock_movements (tenant_id, source_item_type, source_item_id) WHERE source_item_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_created_idx ON public.plg_inventory_stock_movements (tenant_id, created_at DESC);\n-- one key + line per tenant: the ledger cannot hold the same line of the same\n-- operation twice (the operations table below is the replay authority)\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_stock_movements_idem_key\n ON public.plg_inventory_stock_movements (tenant_id, idempotency_key, line_no) WHERE idempotency_key IS NOT NULL;\n-- a movement is reversed at most once\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_stock_movements_reverses_key\n ON public.plg_inventory_stock_movements (reverses_movement_id) WHERE reverses_movement_id IS NOT NULL;\n\n-- Append-only: a correction is a new row (kind = 'reverse'), never an edit.\n-- Append-only means THE FACTS OF A MOVEMENT NEVER CHANGE: quantity, product,\n-- date, cost, location. It does not mean the row can never be touched again, and\n-- the difference decides whether a pool with years of stock behind it can adopt\n-- a scoping column that did not exist when those rows were written.\n--\n-- DELETE is always refused. UPDATE is refused unless it is exactly one thing: a\n-- scope column going from NULL to a value with every other column identical.\n-- That is a backfill, not a rewrite \u2014 018 needs it, and nothing else gets in:\n-- moving a unit that was already set is still a reversal's job.\nCREATE OR REPLACE FUNCTION app.inventory_movements_append_only()\nRETURNS trigger LANGUAGE plpgsql 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 $$;\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_append_only ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_append_only\n BEFORE UPDATE OR DELETE ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_append_only();\n\nDROP TRIGGER IF EXISTS plg_inventory_stock_locations_before_update ON public.plg_inventory_stock_locations;\nCREATE TRIGGER plg_inventory_stock_locations_before_update\n BEFORE UPDATE ON public.plg_inventory_stock_locations\n FOR EACH ROW EXECUTE FUNCTION app.inventory_locations_before_update();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 positions (derived balances) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nALTER TABLE public.plg_inventory_stock_positions ADD COLUMN IF NOT EXISTS measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id);\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_positions'\n AND column_name = 'unit_cost' AND (numeric_precision, numeric_scale) IS DISTINCT FROM (16, 4)) THEN\n ALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN unit_cost TYPE numeric(16,4);\n END IF;\nEND $$;\nALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN unit_cost SET DEFAULT 0;\nUPDATE public.plg_inventory_stock_positions SET unit_cost = 0 WHERE unit_cost IS NULL;\nALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN unit_cost SET NOT NULL;\n\n-- legacy positions without a location: park them at the tenant's oldest location\nUPDATE public.plg_inventory_stock_positions p\n SET stock_location_id = (SELECT l.id FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = p.tenant_id ORDER BY l.created_at, l.id LIMIT 1)\n WHERE p.stock_location_id IS NULL;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_positions WHERE stock_location_id IS NULL) THEN\n ALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN stock_location_id SET NOT NULL;\n ALTER TABLE public.plg_inventory_stock_positions DROP CONSTRAINT IF EXISTS plg_inventory_stock_positions_location_required;\n ELSIF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_positions_location_required' AND conrelid = 'public.plg_inventory_stock_positions'::regclass) THEN\n ALTER TABLE public.plg_inventory_stock_positions\n ADD CONSTRAINT plg_inventory_stock_positions_location_required CHECK (stock_location_id IS NOT NULL) NOT VALID;\n RAISE NOTICE 'plg_inventory_stock_positions: legacy rows without a location and no location to park them \u2014 NOT NULL left as NOT VALID check';\n END IF;\nEND $$;\n\n-- merge legacy duplicates of one position identity (sum, weighted cost) so the\n-- unique index below can exist\nDO $$\nDECLARE d record;\nBEGIN\n IF EXISTS (SELECT 1 FROM public.plg_inventory_stock_positions\n GROUP BY tenant_id, product_id, stock_location_id, coalesce(batch_number, ''), coalesce(expiration_date, 'infinity'::date)\n HAVING count(*) > 1) THEN\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n FOR d IN\n SELECT tenant_id, product_id, stock_location_id, coalesce(batch_number, '') AS b, coalesce(expiration_date, 'infinity'::date) AS e,\n sum(quantity) AS qty,\n CASE WHEN sum(quantity) <> 0 THEN sum(quantity * unit_cost) / sum(quantity) ELSE max(unit_cost) END AS cost,\n (array_agg(id ORDER BY created_at, id))[1] AS keep_id\n FROM public.plg_inventory_stock_positions\n GROUP BY 1, 2, 3, 4, 5 HAVING count(*) > 1\n LOOP\n UPDATE public.plg_inventory_stock_positions SET quantity = d.qty, unit_cost = coalesce(d.cost, 0) WHERE id = d.keep_id;\n DELETE FROM public.plg_inventory_stock_positions\n WHERE tenant_id = d.tenant_id AND product_id = d.product_id AND stock_location_id = d.stock_location_id\n AND coalesce(batch_number, '') = d.b AND coalesce(expiration_date, 'infinity'::date) = d.e AND id <> d.keep_id;\n END LOOP;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n END IF;\nEND $$;\n\nSELECT app.scaffold_table('public.plg_inventory_stock_positions', 'inventory.stock_position', 'inventory', false, 'enforce');\n\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_stock_positions_identity_key\n ON public.plg_inventory_stock_positions (tenant_id, product_id, stock_location_id, coalesce(batch_number, ''), coalesce(expiration_date, 'infinity'::date));\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_positions_location_idx ON public.plg_inventory_stock_positions (tenant_id, stock_location_id, product_id);\nCREATE INDEX IF NOT EXISTS plg_inventory_stock_positions_expiry_idx ON public.plg_inventory_stock_positions (tenant_id, expiration_date) WHERE expiration_date IS NOT NULL;\n\n-- positions.unit_id mirrors the location's unit \u2014 that is what the RLS template scopes on\nUPDATE public.plg_inventory_stock_positions p\n SET unit_id = l.unit_id\n FROM public.plg_inventory_stock_locations l\n WHERE l.id = p.stock_location_id AND p.unit_id IS DISTINCT FROM l.unit_id;\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_positions_quantity_check' AND conrelid = 'public.plg_inventory_stock_positions'::regclass) THEN\n IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_positions WHERE quantity < 0) THEN\n ALTER TABLE public.plg_inventory_stock_positions ADD CONSTRAINT plg_inventory_stock_positions_quantity_check CHECK (quantity >= 0);\n ELSE\n ALTER TABLE public.plg_inventory_stock_positions ADD CONSTRAINT plg_inventory_stock_positions_quantity_check CHECK (quantity >= 0) NOT VALID;\n RAISE NOTICE 'plg_inventory_stock_positions: negative legacy balances \u2014 quantity >= 0 left as NOT VALID check';\n END IF;\n END IF;\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 product settings (min quantity) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- The replacement for products.min_stock: per product, tenant-wide (unit_id\n-- NULL) or per unit. Also carries the product's stock unit of measure and its\n-- default location.\nCREATE TABLE IF NOT EXISTS public.plg_inventory_product_settings (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,\n min_quantity numeric(16,4) NOT NULL DEFAULT 0 CHECK (min_quantity >= 0),\n max_quantity numeric(16,4) CHECK (max_quantity IS NULL OR max_quantity >= 0),\n measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id),\n default_location_id uuid REFERENCES public.plg_inventory_stock_locations(id) ON DELETE SET NULL,\n track_batches boolean NOT NULL DEFAULT false,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nSELECT app.scaffold_table('public.plg_inventory_product_settings', 'inventory.product_settings', 'inventory', false, 'enforce');\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_product_settings_product_unit_key\n ON public.plg_inventory_product_settings (tenant_id, product_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid));\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 reservations (consumption hooks) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- reserve \u2192 confirm turns into exactly one 'out' movement; reverse releases or\n-- writes the reversal. Keyed by the source item (the executed service line, the\n-- order item) through idempotency_key.\nCREATE TABLE IF NOT EXISTS public.plg_inventory_reservations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n idempotency_key text NOT NULL,\n line_no integer NOT NULL DEFAULT 1,\n source_type text NOT NULL,\n source_id uuid NOT NULL,\n product_id uuid NOT NULL REFERENCES public.products(id),\n stock_location_id uuid NOT NULL REFERENCES public.plg_inventory_stock_locations(id),\n batch_number text,\n expiration_date date,\n quantity numeric(16,4) NOT NULL CHECK (quantity > 0),\n measurement_unit_id uuid REFERENCES public.plg_inventory_measurement_units(id),\n status text NOT NULL DEFAULT 'reserved' CHECK (status IN ('reserved', 'confirmed', 'reversed')),\n movement_id uuid REFERENCES public.plg_inventory_stock_movements(id),\n reversal_movement_id uuid REFERENCES public.plg_inventory_stock_movements(id),\n reason text,\n confirmed_at timestamptz,\n confirmed_by uuid,\n reversed_at timestamptz,\n reversed_by uuid,\n metadata jsonb NOT NULL DEFAULT '{}'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nSELECT app.scaffold_table('public.plg_inventory_reservations', 'inventory.reservation', 'inventory', false, 'enforce');\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_reservations_key_line\n ON public.plg_inventory_reservations (tenant_id, idempotency_key, line_no);\nCREATE INDEX IF NOT EXISTS plg_inventory_reservations_source_idx\n ON public.plg_inventory_reservations (tenant_id, source_type, source_id);\nCREATE INDEX IF NOT EXISTS plg_inventory_reservations_open_idx\n ON public.plg_inventory_reservations (tenant_id, product_id, stock_location_id) WHERE status = 'reserved';\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 operations (idempotency ledger) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- One row per RPC call and key: replaying the same key returns the stored\n-- result and has no second effect. The unique index is what serializes two\n-- concurrent calls with the same key (the second waits, then reads the result).\nCREATE TABLE IF NOT EXISTS public.plg_inventory_operations (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n kind text NOT NULL CHECK (kind IN ('receive', 'transfer', 'adjust', 'reserve', 'confirm', 'reverse')),\n idempotency_key text NOT NULL,\n status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'done')),\n actor_id uuid,\n request jsonb NOT NULL DEFAULT '{}'::jsonb,\n result jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\nSELECT app.scaffold_table('public.plg_inventory_operations', 'inventory.operation', 'inventory', false, 'enforce');\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_operations_key\n ON public.plg_inventory_operations (tenant_id, kind, idempotency_key);\n\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_stock_movements_operation_fk' AND conrelid = 'public.plg_inventory_stock_movements'::regclass) THEN\n ALTER TABLE 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 END IF;\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 the single-writer gate \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-- Positions are derived; movements are the ledger. Both are written only from\n-- inside the RPCs / the writer trigger, which set fayz.inventory_internal for the\n-- statement. Any other write path \u2014 however privileged \u2014 is refused with a\n-- message that says where to go instead.\nCREATE OR REPLACE FUNCTION app.inventory_internal_only()\nRETURNS trigger LANGUAGE plpgsql 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 $$;\nDROP TRIGGER IF EXISTS plg_inventory_stock_positions_internal_only ON public.plg_inventory_stock_positions;\nCREATE TRIGGER plg_inventory_stock_positions_internal_only\n BEFORE INSERT OR UPDATE OR DELETE ON public.plg_inventory_stock_positions\n FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_internal_only ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_internal_only\n BEFORE INSERT ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\nDROP TRIGGER IF EXISTS plg_inventory_reservations_internal_only ON public.plg_inventory_reservations;\nCREATE TRIGGER plg_inventory_reservations_internal_only\n BEFORE INSERT OR UPDATE OR DELETE ON public.plg_inventory_reservations\n FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\nDROP TRIGGER IF EXISTS plg_inventory_operations_internal_only ON public.plg_inventory_operations;\nCREATE TRIGGER plg_inventory_operations_internal_only\n BEFORE INSERT OR UPDATE OR DELETE ON public.plg_inventory_operations\n FOR EACH ROW EXECUTE FUNCTION app.inventory_internal_only();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 grants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Reads for authenticated (RLS narrows); writes on the ledger tables revoked \u2014\n-- the spine's blanket GRANT ALL (008) is re-issued on replay, so this file\n-- converges by revoking again after it.\nREVOKE ALL ON public.plg_inventory_stock_locations, public.plg_inventory_stock_positions, public.plg_inventory_stock_movements,\n public.plg_inventory_product_settings, public.plg_inventory_reservations, public.plg_inventory_operations FROM authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_inventory_stock_locations TO authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_inventory_product_settings TO authenticated;\nGRANT SELECT ON public.plg_inventory_stock_positions, public.plg_inventory_stock_movements,\n public.plg_inventory_reservations, public.plg_inventory_operations TO authenticated;\nGRANT ALL ON public.plg_inventory_stock_locations, public.plg_inventory_stock_positions, public.plg_inventory_stock_movements,\n public.plg_inventory_product_settings, public.plg_inventory_reservations, public.plg_inventory_operations TO service_role;\nREVOKE ALL ON public.plg_inventory_stock_locations, public.plg_inventory_stock_positions, public.plg_inventory_stock_movements,\n public.plg_inventory_product_settings, public.plg_inventory_reservations, public.plg_inventory_operations FROM anon;\n\nCOMMENT ON TABLE public.plg_inventory_stock_movements IS\n '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.';\nCOMMENT ON TABLE public.plg_inventory_stock_positions IS\n '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.';\nCOMMENT ON TABLE public.plg_inventory_reservations IS\n 'Consumption hooks: inventory_reserve \u2192 inventory_confirm (one out movement, exactly once) \u2192 inventory_reverse. Keyed by the source item through idempotency_key.';\nCOMMENT ON TABLE public.plg_inventory_operations IS\n 'Idempotency ledger of the inventory RPCs: (tenant, kind, idempotency_key) \u2192 stored result. A replay returns the stored result and has no second effect.';\nCOMMENT ON TABLE public.plg_inventory_product_settings IS\n 'Per-product inventory settings (min/max quantity, stock unit, default location), tenant-wide (unit_id NULL) or per unit. Replaces products.min_stock.';\n";
14
- export declare const MIGRATION_013_INVENTORY_UNITS = "-- ============================================================================\n-- 013_inventory_units.sql \u2014 measurement units and conversions in the inventory\n-- core (PRD 06 R2 / #143). \"bottle \u2194 mL is a conversion, not a hack\".\n--\n-- plg_inventory_measurement_units (003) gains code / kind / is_base and the\n-- scaffold; plg_inventory_unit_conversions holds (from, to, factor), tenant-wide\n-- or per product (a 250 mL bottle of product X: bottle \u2192 mL \u00D7 250);\n-- public.inventory_convert() resolves a quantity between two units. The six\n-- standard units (un, cx, kg, g, L, mL) are seeded per tenant in SQL \u2014 for\n-- every existing tenant here, for every new tenant by trigger \u2014 replacing the\n-- TS-only seed (the TS seedData stays for mock mode).\n-- ============================================================================\n\nALTER TABLE public.plg_inventory_measurement_units ADD COLUMN IF NOT EXISTS code text;\nALTER TABLE public.plg_inventory_measurement_units ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'unit';\nALTER TABLE public.plg_inventory_measurement_units ADD COLUMN IF NOT EXISTS is_base boolean NOT NULL DEFAULT false;\nALTER TABLE public.plg_inventory_measurement_units ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;\nDO $$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_measurement_units_kind_check' AND conrelid = 'public.plg_inventory_measurement_units'::regclass) THEN\n ALTER TABLE public.plg_inventory_measurement_units\n ADD CONSTRAINT plg_inventory_measurement_units_kind_check CHECK (kind IN ('unit', 'mass', 'volume', 'length'));\n END IF;\nEND $$;\n-- legacy rows: derive a code from the abbreviation where none was set\nUPDATE public.plg_inventory_measurement_units SET code = lower(abbreviation) WHERE code IS NULL AND abbreviation IS NOT NULL;\nUPDATE public.plg_inventory_measurement_units SET kind = CASE lower(code)\n WHEN 'kg' THEN 'mass' WHEN 'g' THEN 'mass' WHEN 'mg' THEN 'mass'\n WHEN 'l' THEN 'volume' WHEN 'ml' THEN 'volume'\n WHEN 'm' THEN 'length' WHEN 'cm' THEN 'length' WHEN 'mm' THEN 'length'\n ELSE kind END\n WHERE kind = 'unit' AND lower(code) IN ('kg', 'g', 'mg', 'l', 'ml', 'm', 'cm', 'mm');\n\nSELECT app.scaffold_table('public.plg_inventory_measurement_units', 'inventory.measurement_unit', 'inventory', false, 'enforce');\n\nDO $$\nBEGIN\n IF NOT EXISTS (\n SELECT 1 FROM public.plg_inventory_measurement_units WHERE code IS NOT NULL\n GROUP BY tenant_id, lower(code) HAVING count(*) > 1\n ) THEN\n CREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_measurement_units_code_key\n ON public.plg_inventory_measurement_units (tenant_id, lower(code)) WHERE code IS NOT NULL;\n ELSE\n RAISE NOTICE 'plg_inventory_measurement_units: duplicate codes within a tenant \u2014 unique index skipped';\n END IF;\nEND $$;\n-- one base unit per kind per tenant\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_measurement_units_base_key\n ON public.plg_inventory_measurement_units (tenant_id, kind) WHERE is_base;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 conversions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE TABLE IF NOT EXISTS public.plg_inventory_unit_conversions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,\n from_unit_id uuid NOT NULL REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE,\n to_unit_id uuid NOT NULL REFERENCES public.plg_inventory_measurement_units(id) ON DELETE CASCADE,\n factor numeric(20,8) NOT NULL CHECK (factor > 0),\n product_id uuid REFERENCES public.products(id) ON DELETE CASCADE,\n notes text,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CHECK (from_unit_id <> to_unit_id)\n);\nSELECT app.scaffold_table('public.plg_inventory_unit_conversions', 'inventory.unit_conversion', 'inventory', false, 'enforce');\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_unit_conversions_key\n ON public.plg_inventory_unit_conversions (tenant_id, from_unit_id, to_unit_id, coalesce(product_id, '00000000-0000-0000-0000-000000000000'::uuid));\nCREATE INDEX IF NOT EXISTS plg_inventory_unit_conversions_product_idx\n ON public.plg_inventory_unit_conversions (tenant_id, product_id) WHERE product_id IS NOT NULL;\n\nREVOKE ALL ON public.plg_inventory_measurement_units, public.plg_inventory_unit_conversions FROM authenticated;\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_inventory_measurement_units, public.plg_inventory_unit_conversions TO authenticated;\nGRANT ALL ON public.plg_inventory_measurement_units, public.plg_inventory_unit_conversions TO service_role;\nREVOKE ALL ON public.plg_inventory_measurement_units, public.plg_inventory_unit_conversions FROM anon;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 recipe columns \u2192 units (guarded FKs) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Recipes are out of scope (PRD 06), but their unit columns should point at the\n-- units table where the data allows it: added only when no orphan value exists.\nDO $$\nBEGIN\n IF to_regclass('public.plg_inventory_recipes') IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipes_yield_unit_fk' AND conrelid = 'public.plg_inventory_recipes'::regclass)\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_recipes r\n WHERE r.yield_unit_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_measurement_units u WHERE u.id = r.yield_unit_id)) THEN\n ALTER TABLE 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 END IF;\n IF to_regclass('public.plg_inventory_recipe_ingredients') IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_ingredients_unit_fk' AND conrelid = 'public.plg_inventory_recipe_ingredients'::regclass)\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_recipe_ingredients i\n WHERE i.unit_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_measurement_units u WHERE u.id = i.unit_id)) THEN\n ALTER TABLE 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 END IF;\nEND $$;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 the standard units, per tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION public.inventory_seed_measurement_units(p_tenant uuid)\nRETURNS integer\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\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 $$;\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units(uuid) FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.inventory_seed_measurement_units(uuid) TO service_role;\n\n-- The session variant: a signed-in manager (inventory.manage) seeds her own tenant.\nCREATE OR REPLACE FUNCTION public.inventory_seed_measurement_units()\nRETURNS integer\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\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 $$;\nREVOKE ALL ON FUNCTION public.inventory_seed_measurement_units() FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_seed_measurement_units() TO authenticated, service_role;\n\n-- every existing tenant, now\nDO $$\nDECLARE t record;\nBEGIN\n FOR t IN SELECT id FROM public.tenants LOOP\n PERFORM public.inventory_seed_measurement_units(t.id);\n END LOOP;\nEND $$;\n\n-- every new tenant, by trigger\nCREATE OR REPLACE FUNCTION app.trg_inventory_seed_units_for_tenant()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n PERFORM public.inventory_seed_measurement_units(NEW.id);\n RETURN NULL;\nEND $$;\nDROP TRIGGER IF EXISTS tenants_inventory_seed_units ON public.tenants;\nCREATE TRIGGER tenants_inventory_seed_units\n AFTER INSERT ON public.tenants\n FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_seed_units_for_tenant();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 inventory_convert \u2500\u2500\u2500\u2500\u2500\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-- Resolves qty from one unit to another for a tenant (and optionally a product):\n-- same unit \u2192 qty; a product-specific row beats a tenant-wide one; the inverse\n-- of a row is used when only that direction is defined; otherwise one hop through\n-- the base unit of the kind (kg \u2192 g \u2192 mL is NOT a path: kinds must match).\n-- Raises 22023 when no conversion is defined.\nCREATE OR REPLACE FUNCTION public.inventory_convert(p_tenant uuid, p_product uuid, p_qty numeric, p_from_unit uuid, p_to_unit uuid)\nRETURNS numeric\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''\nAS $$\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 $$;\nREVOKE ALL ON FUNCTION public.inventory_convert(uuid, uuid, numeric, uuid, uuid) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_convert(uuid, uuid, numeric, uuid, uuid) TO authenticated, service_role;\n\nCOMMENT ON TABLE public.plg_inventory_unit_conversions IS\n '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";
15
- export declare const MIGRATION_014_INVENTORY_POSITIONS_AND_VIEWS = "-- ============================================================================\n-- 014_inventory_positions_and_views.sql \u2014 the ONE writer of balances and the\n-- read views (PRD 06 R1/R4 / #143).\n--\n-- A movement row is normalized BEFORE INSERT (locations checked against the\n-- tenant, unit_id and total_cost derived, actor defaulted) and applied to the\n-- positions AFTER INSERT: +quantity at (product, source_location, batch,\n-- expiry), \u2212quantity at destination for a transfer / reversed transfer. Cost on\n-- a position is the moving weighted average of what came in; a transfer carries\n-- the source position's cost, so cost is preserved across units.\n--\n-- Balances are read through views (security_invoker: the caller's RLS on the\n-- scaffolded tables decides what she sees):\n-- v_inventory_balances per (product, location, batch, expiry) + reserved/available\n-- v_inventory_product_totals per product\n-- v_inventory_low_stock products at or below their min quantity (settings)\n-- v_inventory_movements the ledger with product / location names\n--\n-- products.stock / products.min_stock stay as one-way MIRRORS (see 005 header).\n-- ============================================================================\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 BEFORE INSERT: normalize + validate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION app.inventory_movements_before_insert()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_before_insert ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_before_insert\n BEFORE INSERT ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_before_insert();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 apply one effect to one position \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- Upserts the position identity and returns the resulting (quantity, unit_cost).\n-- Positive effect: moving weighted average with p_unit_cost. Negative effect:\n-- cost unchanged; refuses to go below zero (23514) \u2014 the RPCs check first with a\n-- friendlier message, this is the last line of defense.\nCREATE OR REPLACE FUNCTION app.inventory_apply_to_position(\n p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date,\n p_effect numeric, p_unit_cost numeric, p_measurement_unit uuid,\n OUT o_quantity numeric, OUT o_unit_cost numeric)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_apply_to_position(uuid, uuid, uuid, text, date, numeric, numeric, uuid) FROM public, anon, authenticated;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 the products.stock mirror \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n-- One-way, inventory \u2192 products.stock = \u03A3 positions of the product. Guarded on\n-- the column still existing so a later catalog decision to drop it costs nothing here.\nCREATE OR REPLACE FUNCTION app.inventory_refresh_product_stock(p_tenant uuid, p_product uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'stock') THEN\n EXECUTE 'UPDATE public.products p SET stock = s.qty FROM (SELECT coalesce(sum(quantity), 0) AS qty FROM public.plg_inventory_stock_positions x WHERE x.tenant_id = $1 AND x.product_id = $2) s WHERE p.id = $2 AND p.tenant_id = $1 AND p.stock IS DISTINCT FROM s.qty'\n USING p_tenant, p_product;\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION app.inventory_refresh_product_stock(uuid, uuid) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_refresh_product_min_stock(p_tenant uuid, p_product uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'min_stock') THEN\n EXECUTE 'UPDATE public.products p SET min_stock = s.q FROM (SELECT (SELECT min_quantity FROM public.plg_inventory_product_settings x WHERE x.tenant_id = $1 AND x.product_id = $2 AND x.unit_id IS NULL) AS q) s WHERE p.id = $2 AND p.tenant_id = $1 AND p.min_stock IS DISTINCT FROM s.q'\n USING p_tenant, p_product;\n END IF;\nEND $$;\nREVOKE ALL ON FUNCTION app.inventory_refresh_product_min_stock(uuid, uuid) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.trg_inventory_product_settings_mirror()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n IF TG_OP = 'DELETE' THEN\n PERFORM app.inventory_refresh_product_min_stock(OLD.tenant_id, OLD.product_id);\n RETURN OLD;\n END IF;\n PERFORM app.inventory_refresh_product_min_stock(NEW.tenant_id, NEW.product_id);\n RETURN NEW;\nEND $$;\nDROP TRIGGER IF EXISTS plg_inventory_product_settings_mirror ON public.plg_inventory_product_settings;\nCREATE TRIGGER plg_inventory_product_settings_mirror\n AFTER INSERT OR UPDATE OR DELETE ON public.plg_inventory_product_settings\n FOR EACH ROW EXECUTE FUNCTION app.trg_inventory_product_settings_mirror();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 AFTER INSERT: the writer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION app.inventory_movements_after_insert()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 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 END IF;\n PERFORM app.inventory_refresh_product_stock(NEW.tenant_id, NEW.product_id);\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n RETURN NULL;\nEND $$;\nDROP TRIGGER IF EXISTS plg_inventory_stock_movements_after_insert ON public.plg_inventory_stock_movements;\nCREATE TRIGGER plg_inventory_stock_movements_after_insert\n AFTER INSERT ON public.plg_inventory_stock_movements\n FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_after_insert();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 views \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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-- Product name/sku for the ledger reader: the caller's own tenant only, but not\n-- through products' row policies \u2014 a reader of the stock ledger sees which\n-- product moved even where the catalog's legacy RLS (tenant_members) would hide\n-- the row from her. Nothing else of the product is exposed.\nCREATE OR REPLACE FUNCTION app.inventory_product_label(p_product uuid)\nRETURNS TABLE (name text, sku text)\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' 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$$;\nREVOKE ALL ON FUNCTION app.inventory_product_label(uuid) FROM public, anon;\nGRANT EXECUTE ON FUNCTION app.inventory_product_label(uuid) TO authenticated, service_role;\n\nDROP VIEW IF EXISTS public.v_inventory_low_stock;\nDROP VIEW IF EXISTS public.v_inventory_product_totals;\nDROP VIEW IF EXISTS public.v_inventory_balances;\nDROP VIEW IF EXISTS public.v_inventory_movements;\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)::numeric 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 ON true\n LEFT JOIN LATERAL (\n 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\n AND coalesce(x.batch_number, '') = coalesce(p.batch_number, '')\n AND coalesce(x.expiration_date, 'infinity'::date) = coalesce(p.expiration_date, 'infinity'::date)\n AND x.status = 'reserved'\n ) r ON true;\n\nCREATE VIEW public.v_inventory_product_totals WITH (security_invoker = true) AS\n SELECT b.tenant_id,\n b.product_id,\n max(b.product_name) AS product_name,\n max(b.product_sku) AS product_sku,\n sum(b.quantity)::numeric AS on_hand,\n sum(b.reserved_quantity)::numeric AS reserved_quantity,\n sum(b.available_quantity)::numeric AS available_quantity,\n CASE WHEN sum(b.quantity) > 0 THEN round(sum(b.quantity * b.unit_cost) / sum(b.quantity), 4) ELSE max(b.unit_cost) END::numeric AS avg_unit_cost,\n sum(b.total_value)::numeric AS total_value,\n count(DISTINCT b.stock_location_id)::integer AS location_count,\n count(*)::integer AS position_count,\n min(b.expiration_date) AS next_expiration,\n max(b.updated_at) AS updated_at\n FROM public.v_inventory_balances b\n GROUP BY b.tenant_id, b.product_id;\n\n-- Low stock: tenant-wide (per product, unit_id NULL: settings row without a unit)\n-- and per unit (settings row for that unit, else the tenant-wide minimum).\nCREATE VIEW public.v_inventory_low_stock WITH (security_invoker = true) AS\n WITH per_unit AS (\n SELECT b.tenant_id, b.product_id, b.unit_id, sum(b.quantity) AS on_hand, sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b GROUP BY b.tenant_id, b.product_id, b.unit_id\n ),\n tenant_wide AS (\n SELECT b.tenant_id, b.product_id, NULL::uuid AS unit_id, sum(b.quantity) AS on_hand, sum(b.available_quantity) AS available_quantity\n FROM public.v_inventory_balances b GROUP BY b.tenant_id, b.product_id\n ),\n scopes AS (SELECT * FROM per_unit UNION ALL SELECT * FROM tenant_wide),\n settings AS (\n SELECT s.tenant_id, s.product_id, s.unit_id, s.min_quantity, s.max_quantity\n FROM public.plg_inventory_product_settings s\n )\n SELECT s.tenant_id, s.product_id, pl.name AS product_name, pl.sku AS product_sku, 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, 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 sc.unit_id IS NOT DISTINCT FROM s.unit_id\n LEFT JOIN LATERAL app.inventory_product_label(s.product_id) pl ON true\n WHERE s.min_quantity > 0 AND coalesce(sc.on_hand, 0) <= 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 -- the SDK's historical label, for readers that still speak it\n CASE m.kind WHEN 'in' THEN 'entry' WHEN 'out' THEN CASE WHEN m.reason ILIKE 'loss%' OR m.document_type = 'loss' THEN 'loss' ELSE 'exit' END\n WHEN 'transfer' THEN 'transfer' WHEN 'adjust' THEN 'adjustment' WHEN 'reverse' THEN 'adjustment' 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 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\nGRANT SELECT ON public.v_inventory_balances, public.v_inventory_product_totals, public.v_inventory_low_stock, public.v_inventory_movements\n TO authenticated, service_role;\nREVOKE ALL ON public.v_inventory_balances, public.v_inventory_product_totals, public.v_inventory_low_stock, public.v_inventory_movements FROM anon;\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).';\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.';\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).';\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";
16
- export declare const MIGRATION_015_INVENTORY_RPCS = "-- ============================================================================\n-- 015_inventory_rpcs.sql \u2014 the write surface of the inventory contract\n-- (PRD 06 R1/R3 / #143). Six RPCs, all SECURITY DEFINER SET search_path = '':\n--\n-- inventory_receive(location, lines, document, key) 'in' \u2014 initial load without invoice, purchase, \u2026\n-- inventory_transfer(from, to, lines, key) 'transfer' \u2014 cost preserved\n-- inventory_adjust(location, lines, reason, key) 'adjust' \u2014 reason mandatory\n-- inventory_reserve(source_type, source_id, lines, key) reservation rows (no movement yet)\n-- inventory_confirm(key) one 'out' per reservation, exactly once\n-- inventory_reverse(key, reason) releases reservations / writes reversals\n--\n-- Actor = auth.uid(); tenant = app.current_tenant_id() (never a parameter);\n-- every touched location must be in the caller's unit scope (app.has_unit) and\n-- the action permission is checked against that unit (app.has_permission with\n-- inventory.create / inventory.edit \u2014 manage implies both). Denied \u2192 42501.\n-- Idempotent: every call records (tenant, kind, key) in plg_inventory_operations;\n-- replaying a key returns the stored result and has no second effect. A key not\n-- given is generated for receive/transfer/adjust; for reserve it defaults to\n-- '<source_type>:<source_id>' \u2014 the source item IS the key. Each mutating call\n-- writes one row into public.audit_logs.\n--\n-- Lines carry an optional measurement_unit_id: when it differs from the\n-- product's stock unit (plg_inventory_product_settings.measurement_unit_id) the\n-- quantity is converted through public.inventory_convert \u2014 the bottle \u2194 mL case.\n--\n-- Server-plane callers (edge functions) impersonate the already-verified actor\n-- by setting request.jwt.claims locally, as the agent RPCs of the spine do \u2014\n-- there is no service_role bypass of the permission checks.\n-- ============================================================================\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 internal helpers (schema app) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nCREATE OR REPLACE FUNCTION app.inventory_require_tenant()\nRETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_require_tenant() FROM public, anon, authenticated;\n\n-- The location must exist in the tenant, be in the caller's units, and the\n-- caller must hold p_perm for its unit. Returns the location's unit.\nCREATE OR REPLACE FUNCTION app.inventory_authorize_location(p_tenant uuid, p_location uuid, p_perm text)\nRETURNS uuid LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_authorize_location(uuid, uuid, text) FROM public, anon, authenticated;\n\n-- Start an operation: returns the new operation id, or the stored result of a\n-- previous call with the same (kind, key) \u2014 the caller then returns it as-is.\nCREATE OR REPLACE FUNCTION app.inventory_begin_operation(p_tenant uuid, p_kind text, p_key text, p_request jsonb, OUT op_id uuid, OUT existing jsonb)\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_begin_operation(uuid, text, text, jsonb) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_finish_operation(p_tenant uuid, p_op uuid, p_kind text, p_key text, p_result jsonb, p_audit jsonb)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_finish_operation(uuid, uuid, text, text, jsonb, jsonb) FROM public, anon, authenticated;\n\n-- A validated line: product in tenant, quantity in the required range (mode\n-- 'positive' | 'signed' | 'absolute'), expressed in the product's stock unit\n-- (converted when the line names another unit).\nCREATE OR REPLACE FUNCTION app.inventory_parse_line(p_tenant uuid, p_line jsonb, p_mode text DEFAULT 'positive',\n OUT product_id uuid, OUT quantity numeric, OUT unit_cost numeric, OUT batch_number text, OUT expiration_date date,\n OUT measurement_unit_id uuid, OUT declared jsonb, OUT notes text)\nLANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_parse_line(uuid, jsonb, text) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_movement_json(p_id uuid)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' 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$$;\nREVOKE ALL ON FUNCTION app.inventory_movement_json(uuid) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_position_json(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' 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$$;\nREVOKE ALL ON FUNCTION app.inventory_position_json(uuid, uuid, uuid, text, date) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_reservation_json(p_id uuid)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' 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$$;\nREVOKE ALL ON FUNCTION app.inventory_reservation_json(uuid) FROM public, anon, authenticated;\n\n-- available = on hand \u2212 open reservations at the same position identity\nCREATE OR REPLACE FUNCTION app.inventory_available(p_tenant uuid, p_product uuid, p_location uuid, p_batch text, p_expiry date)\nRETURNS numeric LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' 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$$;\nREVOKE ALL ON FUNCTION app.inventory_available(uuid, uuid, uuid, text, date) FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION app.inventory_check_lines(p_lines jsonb)\nRETURNS void LANGUAGE plpgsql IMMUTABLE 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_check_lines(jsonb) FROM public, anon, authenticated;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_receive \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- lines: [{product_id, quantity, unit_cost?, batch_number?, expiration_date?, measurement_unit_id?, notes?}]\n-- document: {type?: 'initial_load'|'invoice'|'purchase'|'return'|\u2026, ref?, supplier_id?, date?, notes?}\nCREATE OR REPLACE FUNCTION public.inventory_receive(p_location uuid, p_lines jsonb, p_document jsonb DEFAULT '{}'::jsonb, p_idempotency_key text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_receive(uuid, jsonb, jsonb, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_receive(uuid, jsonb, jsonb, text) TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_transfer \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- lines: [{product_id, quantity, batch_number?, expiration_date?, measurement_unit_id?}]\n-- inventory.edit at the source unit, inventory.create at the destination unit;\n-- the movement carries the source position's unit cost (cost preserved).\nCREATE OR REPLACE FUNCTION public.inventory_transfer(p_from_location uuid, p_to_location uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_transfer(uuid, uuid, jsonb, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_transfer(uuid, uuid, jsonb, text) TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_adjust \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- lines: [{product_id, delta (signed) | set_quantity (absolute), unit_cost?, batch_number?, expiration_date?, measurement_unit_id?}]\n-- reason is mandatory; inventory.edit at the location's unit.\nCREATE OR REPLACE FUNCTION public.inventory_adjust(p_location uuid, p_lines jsonb, p_reason text, p_idempotency_key text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_adjust(uuid, jsonb, text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_adjust(uuid, jsonb, text, text) TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_reserve \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- lines: [{product_id, location_id | stock_location_id, quantity, batch_number?, expiration_date?, measurement_unit_id?}]\n-- Holds stock for a source item (the executed service line, the order item)\n-- without moving it: available = on hand \u2212 reserved. inventory.create at each\n-- location's unit. Key defaults to '<source_type>:<source_id>'.\nCREATE OR REPLACE FUNCTION public.inventory_reserve(p_source_type text, p_source_id uuid, p_lines jsonb, p_idempotency_key text DEFAULT NULL)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_reserve(text, uuid, jsonb, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_reserve(text, uuid, jsonb, text) TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_confirm \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- Turns every 'reserved' line of the key into one 'out' movement, exactly once\n-- (compare-and-set on status). inventory.edit at each location's unit \u2014 the\n-- second check of RN-EST-01 is who confirms; who approved is recorded.\nCREATE OR REPLACE FUNCTION public.inventory_confirm(p_idempotency_key text)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_confirm(text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_confirm(text) TO authenticated, service_role;\n\n-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 inventory_reverse \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n-- Undoes what a key did: open reservations are released; confirmed ones get a\n-- 'reverse' movement (+quantity back at the source location, original cost);\n-- movements of a receive / transfer / adjust key are reversed row by row (a\n-- reversal is a new row pointing back through reverses_movement_id; a row is\n-- reversed at most once). Reason mandatory; inventory.edit at each unit.\nCREATE OR REPLACE FUNCTION public.inventory_reverse(p_idempotency_key text, p_reason text)\nRETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION public.inventory_reverse(text, text) FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_reverse(text, text) TO authenticated, service_role;\n";
17
- export declare const MIGRATION_016_INVENTORY_LEGACY_BACKFILL = "-- ============================================================================\n-- 016_inventory_legacy_backfill.sql \u2014 the catalog's stock columns hand over to\n-- the ledger (PRD 04 R5 / PRD 06 R4 / #143).\n--\n-- Before this slice the inventory TS provider wrote products.stock directly and\n-- products.min_stock was the minimum. Now:\n-- \u2022 a product that has NO ledger yet but a positive products.stock gets one\n-- opening 'in' movement (document_type 'legacy_backfill') into the tenant's\n-- oldest active stock location \u2014 created as \"Estoque\" at the hq unit when the\n-- tenant has none \u2014 so \u03A3 movements = positions = the number people saw;\n-- \u2022 a positive products.min_stock without a settings row becomes the tenant-wide\n-- plg_inventory_product_settings.min_quantity.\n-- Products the shop owns (a plg_shop_products row) are skipped: their stock is\n-- the shop ledger's (docs/data/phase-0/06-inventory-base.md, \"convergence\").\n--\n-- Guarded per product on \"no movements and no positions yet\", so a replay after\n-- the first pass \u2014 or after any real movement \u2014 writes nothing. Both loops are\n-- no-ops when the columns are gone.\n-- ============================================================================\nDO $$\nDECLARE\n v_has_stock boolean := EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'stock');\n v_has_min boolean := EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'min_stock');\n v_shop boolean := to_regclass('public.plg_shop_products') IS NOT NULL;\n r record;\n v_loc uuid;\n v_hq uuid;\n v_n integer := 0;\nBEGIN\n IF v_has_stock THEN\n PERFORM set_config('fayz.inventory_internal', 'on', true);\n FOR r IN EXECUTE format($q$\n SELECT p.id, p.tenant_id, p.stock, p.cost\n FROM public.products p\n WHERE p.stock IS NOT NULL AND p.stock > 0\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements m WHERE m.tenant_id = p.tenant_id AND m.product_id = p.id)\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_stock_positions x WHERE x.tenant_id = p.tenant_id AND x.product_id = p.id)\n %s\n ORDER BY p.tenant_id, p.created_at, p.id\n $q$, CASE WHEN v_shop THEN 'AND NOT EXISTS (SELECT 1 FROM public.plg_shop_products s WHERE s.product_id = p.id OR s.id = p.id)' ELSE '' END)\n LOOP\n SELECT l.id INTO v_loc FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = r.tenant_id AND l.is_active AND l.unit_id IS NOT NULL\n ORDER BY l.created_at, l.id LIMIT 1;\n IF v_loc IS NULL THEN\n SELECT u.id INTO v_hq FROM app.units u WHERE u.tenant_id = r.tenant_id AND u.kind = 'hq';\n IF v_hq IS NULL THEN\n RAISE NOTICE 'inventory backfill: tenant % has no stock location and no hq unit \u2014 product % keeps its legacy products.stock only', r.tenant_id, r.id;\n CONTINUE;\n END IF;\n INSERT INTO public.plg_inventory_stock_locations (tenant_id, unit_id, name, description, is_active)\n VALUES (r.tenant_id, v_hq, 'Estoque', 'Created by the inventory legacy backfill (009)', true)\n RETURNING id INTO v_loc;\n END IF;\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, kind, quantity, unit_cost, source_location_id, document_type, document_number, notes, idempotency_key, line_no)\n VALUES\n (r.tenant_id, r.id, 'in', r.stock, coalesce(r.cost, 0), v_loc, 'legacy_backfill', 'products.stock',\n 'Opening balance copied from products.stock by migration 009', 'legacy_backfill:' || r.id::text, 1)\n ON CONFLICT DO NOTHING;\n v_n := v_n + 1;\n END LOOP;\n PERFORM set_config('fayz.inventory_internal', 'off', true);\n IF v_n > 0 THEN\n RAISE NOTICE 'inventory backfill: % product(s) received an opening movement from products.stock', v_n;\n END IF;\n END IF;\n\n IF v_has_min THEN\n EXECUTE $q$\n INSERT INTO public.plg_inventory_product_settings (tenant_id, product_id, min_quantity)\n SELECT p.tenant_id, p.id, p.min_stock\n FROM public.products p\n WHERE p.min_stock IS NOT NULL AND p.min_stock > 0\n AND NOT EXISTS (SELECT 1 FROM public.plg_inventory_product_settings s WHERE s.tenant_id = p.tenant_id AND s.product_id = p.id AND s.unit_id IS NULL)\n ON CONFLICT DO NOTHING\n $q$;\n END IF;\nEND $$;\n\n-- The columns are mirrors from now on: say so where a DBA looks first.\nDO $$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'stock') THEN\n COMMENT ON COLUMN public.products.stock IS 'DEPRECATED mirror (inventory plugin 007): \u03A3 plg_inventory_stock_positions.quantity of the product, refreshed after every movement. Read v_inventory_product_totals; never write.';\n END IF;\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'min_stock') THEN\n COMMENT ON COLUMN public.products.min_stock IS 'DEPRECATED mirror (inventory plugin 007): plg_inventory_product_settings.min_quantity of the tenant-wide row. Write the settings row; never this column.';\n END IF;\nEND $$;\n";
18
- export declare const MIGRATION_017_RESERVATION_ONE_PER_SOURCE = "-- 017_reservation_one_per_source.sql\n--\n-- The same order line could be consumed twice. Fix for #203.\n--\n-- #143's criterion is an \"idempotent consumption RPC keyed by SOURCE ITEM\". What\n-- shipped is idempotent by KEY: `inventory_reserve` only *defaults* its key to\n-- `source_type:source_id` (008:461), and plg_inventory_reservations_source_idx\n-- (005:371) is a plain index. A caller that passes its own key reserves the same\n-- line again:\n--\n-- inventory_reserve('order_item', <id>, \u2026, key => 'keyA') -> reservation 1\n-- inventory_reserve('order_item', <id>, \u2026, key => 'keyB') -> reservation 2\n-- => 4 units reserved; after confirming both, two 'out' movements for one line\n--\n-- Stock leaves twice for one sale, and because movements are append-only the\n-- correction is a manual reversal.\n--\n-- The source item is the unit of consumption, so it is the unit of uniqueness:\n-- one live reservation per (tenant, source_type, source_id, line_no). A reversed\n-- reservation frees the line again, which is what makes \"reverse, then reserve\n-- afresh\" the way to change a line \u2014 not a second reservation next to the first.\n--\n-- Two layers, same as elsewhere in this chain: a trigger that explains itself,\n-- and a partial unique index behind it that a race cannot talk its way past.\n--\n-- Replay-safe. If a pool already double-consumed a line the index creation FAILS\n-- \u2014 deliberately: that is stock which left twice, and it needs a human, not a\n-- migration quietly picking a winner.\n\nCREATE OR REPLACE FUNCTION app.inventory_reservation_one_per_source()\nRETURNS trigger LANGUAGE plpgsql SET search_path = '' 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 $$;\nREVOKE ALL ON FUNCTION app.inventory_reservation_one_per_source() FROM public, anon, authenticated;\n\nDROP TRIGGER IF EXISTS plg_inventory_reservations_one_per_source ON public.plg_inventory_reservations;\nCREATE TRIGGER plg_inventory_reservations_one_per_source\n BEFORE INSERT OR UPDATE OF status, source_type, source_id, line_no\n ON public.plg_inventory_reservations\n FOR EACH ROW EXECUTE FUNCTION app.inventory_reservation_one_per_source();\n\n-- The backstop the trigger cannot be: two concurrent reserves both read \"no\n-- existing row\" and both insert. The index is what makes the second one fail.\nCREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_reservations_one_live_per_source_uidx\n ON public.plg_inventory_reservations (tenant_id, source_type, source_id, line_no)\n WHERE status <> 'reversed';\n";
19
- export declare const MIGRATION_018_SCOPE_LOCALITY = "-- The inventory tables declare what unit_id means on them (#225). See\n-- plugin-financial 028 for why this cannot live in core 126.\n--\n-- Stock happens somewhere: a location, a movement, a position and a reservation\n-- each belong to exactly one branch. Measurement units, unit conversions,\n-- product settings and the idempotency ledger are network configuration or\n-- bookkeeping and stay unit_optional.\n-- WHERE THE UNIT COMES FROM ON A POOL THAT PREDATES THE QUESTION. Declaring a\n-- table `unit` enforced refuses it while any row still answers NULL, and a pool\n-- with years of stock behind it has plenty: resto carried 51 movements from\n-- before a movement had to say which branch it happened in.\n--\n-- The answer is already in the data. A movement happened at a shelf, and the\n-- shelf belongs to a branch \u2014 so the movement's unit is the location's, derived\n-- rather than guessed. Only the rows with no location at all fall back to the\n-- tenant's headquarters, which is where stock with no stated place has always\n-- implicitly been.\n--\n-- Additive and idempotent: it fills NULLs and never overwrites a unit that is\n-- already set.\nDO $$\nBEGIN\n IF to_regclass('public.plg_inventory_stock_movements') IS NOT NULL THEN\n UPDATE public.plg_inventory_stock_movements m\n SET unit_id = l.unit_id\n FROM public.plg_inventory_stock_locations l\n WHERE m.unit_id IS NULL AND l.id = m.stock_location_id AND l.unit_id IS NOT NULL;\n\n UPDATE public.plg_inventory_stock_movements m\n SET unit_id = u.id\n FROM app.units u\n WHERE m.unit_id IS NULL AND u.tenant_id = m.tenant_id AND u.kind = 'hq';\n END IF;\n\n IF to_regclass('public.plg_inventory_stock_positions') IS NOT NULL THEN\n UPDATE public.plg_inventory_stock_positions p\n SET unit_id = l.unit_id\n FROM public.plg_inventory_stock_locations l\n WHERE p.unit_id IS NULL AND l.id = p.stock_location_id AND l.unit_id IS NOT NULL;\n\n UPDATE public.plg_inventory_stock_positions p\n SET unit_id = u.id\n FROM app.units u\n WHERE p.unit_id IS NULL AND u.tenant_id = p.tenant_id AND u.kind = 'hq';\n END IF;\n\n IF to_regclass('public.plg_inventory_reservations') IS NOT NULL THEN\n UPDATE public.plg_inventory_reservations r\n SET unit_id = u.id\n FROM app.units u\n WHERE r.unit_id IS NULL AND u.tenant_id = r.tenant_id AND u.kind = 'hq';\n END IF;\n\n IF to_regclass('public.plg_inventory_stock_locations') IS NOT NULL THEN\n UPDATE public.plg_inventory_stock_locations l\n SET unit_id = u.id\n FROM app.units u\n WHERE l.unit_id IS NULL AND u.tenant_id = l.tenant_id AND u.kind = 'hq';\n END IF;\nEND $$;\n\nDO $$\nDECLARE t text;\nBEGIN\n FOREACH t IN ARRAY ARRAY[\n 'plg_inventory_stock_locations','plg_inventory_stock_movements',\n 'plg_inventory_stock_positions','plg_inventory_reservations'] LOOP\n CONTINUE WHEN to_regclass('public.' || t) IS NULL;\n PERFORM app.scaffold_scope(('public.' || t)::regclass, 'unit', true);\n END LOOP;\nEND $$;\n";
20
- export declare const MIGRATION_019_THE_STOCK_MIRROR_ASKS_FIRST = "-- ============================================================================\n-- 019 \u2014 o espelho de estoque pergunta antes de escrever\n--\n-- Metade da arbitragem que a spine/146 declarou. A outra metade \u00E9 do shop.\n--\n-- O 014 escreve `products.stock = \u03A3 posi\u00E7\u00F5es` sempre que uma posi\u00E7\u00E3o muda, e o\n-- 0009 da loja escreve `products.stock = inventory_count` sempre que um produto\n-- \u00E9 salvo. Nenhum dos dois sabe do outro, ent\u00E3o quem dispara por \u00FAltimo ganha \u2014\n-- a #143 da Fase 0, e o motivo de restaurant estar 20 unidades fora e salon 29.\n--\n-- Aqui o espelho passa a perguntar. Onde o inventory \u00E9 o dono \u2014 que \u00E9 o padr\u00E3o,\n-- porque \u00E9 o contrato que o 012 declarou \u2014 nada muda: mesma consulta, mesmo\n-- resultado, mesma linha escrita. Onde a loja \u00E9 quem mant\u00E9m o n\u00FAmero, este\n-- espelho se cala em vez de disputar.\n--\n-- A OMISS\u00C3O \u00C9 O PONTO. Um saldo derivado que sobrescreve o saldo de outro livro\n-- n\u00E3o \u00E9 um espelho, \u00E9 uma corrida \u2014 e o lado que perde n\u00E3o deixa rastro.\n-- ============================================================================\n\nCREATE OR REPLACE FUNCTION app.inventory_refresh_product_stock(p_tenant uuid, p_product uuid)\nRETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$\nBEGIN\n -- A pergunta nova. Sem registro a resposta \u00E9 'inventory', ent\u00E3o toda pool que\n -- n\u00E3o declarou nada continua exatamente como estava.\n IF app.product_stock_owner(p_tenant) <> 'inventory' THEN\n RETURN;\n END IF;\n\n -- Guarda original: a coluna ainda existe? Assim uma decis\u00E3o futura do cat\u00E1logo\n -- de derrub\u00E1-la n\u00E3o custa nada aqui.\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'stock') THEN\n EXECUTE 'UPDATE public.products p SET stock = s.qty FROM (SELECT coalesce(sum(quantity), 0) AS qty FROM public.plg_inventory_stock_positions x WHERE x.tenant_id = $1 AND x.product_id = $2) s WHERE p.id = $2 AND p.tenant_id = $1 AND p.stock IS DISTINCT FROM s.qty'\n USING p_tenant, p_product;\n END IF;\nEND $$;\n\nREVOKE ALL ON FUNCTION app.inventory_refresh_product_stock(uuid, uuid) FROM public, anon, authenticated;\n\n-- min_stock n\u00E3o entra: ele vem de plg_inventory_product_settings e a loja n\u00E3o\n-- tem opini\u00E3o sobre m\u00EDnimo. Um escritor s\u00F3 desde sempre, e continua.\n";
21
- export declare const MIGRATION_020_STOCK_REACTS_TO_A_COMPLETED_SALE = "-- ---------------------------------------------------------------------------\n-- 020_stock_reacts_to_a_completed_sale.sql \u2014 the first real wiring of the event\n-- contract (#277).\n--\n-- The decision (rodada 3, Q2) made the domain-event log the contract between\n-- plugins for business effect. 149 built the consumer. This is the first thing\n-- that actually crosses the boundary through it, and it was chosen because it is\n-- the case that broke: a sale closed on the storefront at 2am moved no stock,\n-- because the only paths that ever moved it were a direct call inside\n-- `shop_place_order` and a screen someone had open.\n--\n-- WHAT IT DOES. Subscribes to `order.completed`. For each line of that Venda\n-- whose product is stock-tracked, it records a `sale` movement \u2014 which is what\n-- the position table derives the balance from, so nothing here writes a balance.\n--\n-- WHY IT CANNOT DOUBLE-COUNT, which is the only thing that matters when two\n-- books existed:\n--\n-- \u00B7 146 already registered WHO maintains a tenant's stock number\n-- (app.product_stock_owner). This handler does nothing at all for a tenant\n-- the shop owns \u2014 the shop's own decrement stays the single writer there\n-- until #276 retires it. On an inventory tenant, the handler is the writer\n-- and there is no second one.\n-- \u00B7 Delivery is keyed (event, plugin, handler) by 149, so the same fact\n-- cannot be delivered twice even if the consumer runs twice.\n-- \u00B7 And the movement itself carries the order id in `document_number`, so a\n-- replay that somehow got past both is still visible as one row per line\n-- rather than two silent decrements.\n--\n-- WHY IT IS NOT A TRIGGER ON orders. A trigger would run inside the writer's\n-- transaction: a stock failure would roll back the sale, and the base would\n-- depend on an optional plugin being installed. The whole point of the log is\n-- that the fact survives the reaction failing.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION public.plg_inventory_on_order_completed(p_event jsonb)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\nDECLARE\n v_tenant uuid := nullif(p_event ->> 'tenant_id', '')::uuid;\n v_order uuid := nullif(p_event ->> 'subject_id', '')::uuid;\n v_location uuid;\n v_line record;\n v_moved integer := 0;\n v_skipped 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\n -- 146's register: on a tenant whose stock number the shop maintains, this\n -- plugin is not the writer and must not act. Silence here is the correct\n -- behaviour, and the count says so out loud.\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 -- The default location of the unit that sold, falling back to the tenant's\n -- default. A tenant with no stock location has no stock to move.\n SELECT l.id INTO v_location\n FROM public.plg_inventory_stock_locations l\n WHERE l.tenant_id = v_tenant\n ORDER BY (l.is_default IS TRUE) DESC, l.created_at\n LIMIT 1;\n\n IF v_location IS NULL THEN\n RETURN jsonb_build_object('skipped', 'tenant has no stock location');\n END IF;\n\n FOR v_line IN\n SELECT oi.product_id, oi.quantity\n FROM public.order_items oi\n JOIN public.products p ON p.id = oi.product_id\n WHERE oi.order_id = v_order\n AND oi.tenant_id = v_tenant\n AND oi.product_id IS NOT NULL\n AND coalesce(oi.quantity, 0) > 0\n AND p.kind = 'good' -- a service consumes nothing\n LOOP\n -- One movement per line. The trigger on the movements table is what applies\n -- it to the position; this never writes a balance itself.\n INSERT INTO public.plg_inventory_stock_movements\n (tenant_id, product_id, quantity, movement_type, stock_location_id, document_number, reason)\n VALUES (v_tenant, v_line.product_id, v_line.quantity, 'sale', v_location, v_order::text,\n 'order.completed via the event log (#277)');\n v_moved := v_moved + 1;\n END LOOP;\n\n RETURN jsonb_build_object('movements', v_moved, 'skipped_lines', v_skipped, 'order_id', v_order);\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_inventory_on_order_completed(jsonb) IS\n 'Records a sale movement per line of a completed Venda (#277). Does nothing on a tenant whose stock number the shop maintains (146) \u2014 that is what keeps one writer per book.';\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_order_completed(jsonb) FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_inventory_on_order_completed(jsonb) TO service_role;\n\n-- app.product_stock_owner lives in the `app` schema, which a SECURITY DEFINER\n-- function with an empty search_path can reach only by name. This is the public\n-- wrapper the handler calls, and it exists here rather than in the base because\n-- the base has no reason to expose it.\nCREATE OR REPLACE FUNCTION public.app_product_stock_owner(p_tenant uuid)\nRETURNS text LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''\nAS $$ SELECT app.product_stock_owner(p_tenant) $$;\n\nREVOKE ALL ON FUNCTION public.app_product_stock_owner(uuid) FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.app_product_stock_owner(uuid) TO service_role;\n\n-- The subscription: a row, declared by the plugin that reacts, in the plugin's\n-- own migration. Nothing about this touches the base's tables beyond the\n-- registry the base published for exactly this.\nSELECT public.register_event_subscription(\n 'inventory',\n 'order.completed',\n 'plg_inventory_on_order_completed',\n 'A completed Venda consumes stock, one movement per line (#277)',\n 5\n);\n";
22
- export declare const MIGRATION_021_THE_MOVEMENT_WRITES_THE_ARCHETYPE = "-- ---------------------------------------------------------------------------\n-- 021_the_movement_writes_the_archetype.sql \u2014 the ledger feeds the one number\n-- (#276, ADR 0024).\n--\n-- 151 made `public.stock_balances` the archetype and `public.stock_apply` its\n-- only writer. This is the inventory plugin becoming that writer: the movement\n-- it already records now also applies its delta to the archetype, so the base\n-- can answer \"how much is there\" without asking a plugin.\n--\n-- THE GRAIN TRANSLATION, which is the whole substance of this file. The plugin\n-- counts by (product, location, batch, expiry); the archetype counts by\n-- (variant, unit). So:\n--\n-- product \u2192 variant the product's DEFAULT variant. A catalogue that never\n-- created variants has one per product (144's is_default),\n-- and a movement on a product with no variant at all\n-- writes nothing rather than inventing one \u2014 it shows up\n-- in 151's reconciliation instead of as a wrong number.\n-- location \u2192 unit the location's unit, which the plugin already stores.\n-- batch, expiry stay here. They are how this plugin organises what sits\n-- in the unit, and the archetype does not need them to\n-- answer its question.\n--\n-- The plugin's own positions are unchanged: this adds a writer to the base, it\n-- does not move the plugin's book. Two books would be the thing ADR 0024\n-- forbids \u2014 but the archetype is not a second book, it is THE book, and the\n-- positions become its detail. Retiring them is the follow-up, gated on 151's\n-- reconciliation reading zero.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION app.inventory_default_variant(p_tenant uuid, p_product uuid)\nRETURNS uuid\nLANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''\nAS $$\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\n-- N15c: nothing in schema `app` is callable by anon. A function created without\n-- this is granted to PUBLIC by default, which is how a helper becomes a hole.\nREVOKE ALL ON FUNCTION app.inventory_default_variant(uuid, uuid) FROM public, anon, authenticated;\n\nCOMMENT ON FUNCTION app.inventory_default_variant(uuid, uuid) IS\n '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\nCREATE OR REPLACE FUNCTION app.inventory_sync_archetype_balance(\n p_tenant uuid, p_product uuid, p_location uuid, p_delta numeric\n) RETURNS void\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\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\nREVOKE ALL ON FUNCTION app.inventory_sync_archetype_balance(uuid, uuid, uuid, numeric) FROM public, anon, authenticated;\n\nCOMMENT ON FUNCTION app.inventory_sync_archetype_balance(uuid, uuid, uuid, numeric) IS\n '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\n-- The trigger 014 already installed, with the archetype write added. Everything\n-- else about it is unchanged, deliberately: this file is a writer being added,\n-- not a rewrite of the position engine.\nCREATE OR REPLACE FUNCTION app.inventory_movements_after_insert()\nRETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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 app.inventory_refresh_product_stock(NEW.tenant_id, NEW.product_id);\n PERFORM set_config('fayz.inventory_internal', v_prev, true);\n RETURN NULL;\nEND $$;\n\n-- Backfill: every position this plugin already holds, summed to the archetype's\n-- grain. Guarded so a replay adds nothing \u2014 the archetype row is SET to the\n-- position total rather than incremented by it.\nDO $$\nDECLARE\n r record;\nBEGIN\n FOR r IN\n SELECT p.tenant_id,\n app.inventory_default_variant(p.tenant_id, p.product_id) AS variant_id,\n l.unit_id,\n sum(p.quantity) AS on_hand\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 GROUP BY p.tenant_id, app.inventory_default_variant(p.tenant_id, p.product_id), l.unit_id\n LOOP\n CONTINUE WHEN r.variant_id IS NULL;\n INSERT INTO public.stock_balances (tenant_id, variant_id, unit_id, on_hand, updated_by)\n VALUES (r.tenant_id, r.variant_id, r.unit_id, r.on_hand, 'inventory:backfill')\n ON CONFLICT (tenant_id, variant_id, coalesce(unit_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO UPDATE\n SET on_hand = EXCLUDED.on_hand, updated_by = 'inventory:backfill', updated_at = now();\n END LOOP;\nEND $$;\n";
23
- export declare const MIGRATION_022_THE_COUNT_TABLES_JOIN_THE_TEMPLATE = "-- ---------------------------------------------------------------------------\n-- 022_the_count_tables_join_the_template.sql \u2014 two tables that carry tenant_id\n-- and were never given row security (#283).\n--\n-- Found by the plugin review the same night it was written, which is the\n-- argument for having it: `plg_inventory_count_sessions` and\n-- `plg_inventory_count_items` were added by 010 and left out of 011's scaffold\n-- list. On a shared cluster \u2014 many businesses in one database \u2014 a table with\n-- tenant_id and no policy is every tenant's stock count readable by every other.\n--\n-- Nothing bespoke: the same platform template every other inventory table went\n-- through. They land ENFORCED rather than shadowed, and that is not a shortcut \u2014\n-- shadow exists to measure a new predicate against an existing one before it\n-- starts refusing, and here there is no existing one. A rule cannot take a row\n-- away from someone who was never allowed to see it.\n-- ---------------------------------------------------------------------------\n\nDO $$\nDECLARE\n t record;\nBEGIN\n IF to_regprocedure('app.scaffold_table(regclass, text, text, boolean, text)') IS NULL THEN\n RAISE NOTICE 'plugin-inventory 022: app.scaffold_table missing \u2014 skipped';\n RETURN;\n END IF;\n\n FOR t IN SELECT * FROM (VALUES\n ('plg_inventory_count_sessions', 'inventory.count_session'),\n ('plg_inventory_count_items', 'inventory.count_item')\n ) AS v(table_name, resource_type)\n LOOP\n IF to_regclass('public.' || t.table_name) IS NULL THEN CONTINUE; END IF;\n -- 'enforce', explicitly and deterministically. Shadow exists to measure a\n -- new predicate against an existing one before it starts refusing; here\n -- there is none, so there is nothing to measure and nothing to lose.\n PERFORM app.scaffold_table(('public.' || t.table_name)::regclass, t.resource_type, 'inventory', false, 'enforce');\n EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON public.%I TO authenticated', t.table_name);\n EXECUTE format('GRANT ALL ON public.%I TO service_role', t.table_name);\n END LOOP;\nEND $$;\n\n-- The template is what enables row security; this is the belt to its braces, so\n-- a pool whose scaffold helper is older still gets the table locked rather than\n-- open.\nALTER TABLE public.plg_inventory_count_sessions ENABLE ROW LEVEL SECURITY;\nALTER TABLE public.plg_inventory_count_items ENABLE ROW LEVEL SECURITY;\n";
24
- export declare const MIGRATION_023_PRODUCT_IMAGES_ARE_REGISTERED_FILES = "-- ---------------------------------------------------------------------------\n-- 023_product_images_are_registered_files.sql \u2014 product photos use the file\n-- archetype introduced by the spine in 152.\n--\n-- The bytes remain in Supabase Storage. public.documents is the canonical\n-- register that says what the file is about and where those bytes live. The\n-- legacy products.image_url remains a compatibility projection while existing\n-- readers migrate to the register.\n-- ---------------------------------------------------------------------------\n\nSELECT public.register_kind(\n 'document',\n 'inventory_product_image',\n 'inventory',\n NULL,\n NULL,\n 'A product photo managed by the inventory plugin'\n);\n\nINSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)\nVALUES (\n 'inventory-images',\n 'inventory-images',\n true,\n 8388608,\n ARRAY['image/jpeg','image/png','image/webp','image/heic','image/heif']\n)\nON CONFLICT (id) DO UPDATE SET\n public = EXCLUDED.public,\n file_size_limit = EXCLUDED.file_size_limit,\n allowed_mime_types = EXCLUDED.allowed_mime_types;\n\nDROP POLICY IF EXISTS plg_inventory_images_read ON storage.objects;\nDROP POLICY IF EXISTS plg_inventory_images_insert ON storage.objects;\nDROP POLICY IF EXISTS plg_inventory_images_update ON storage.objects;\nDROP POLICY IF EXISTS plg_inventory_images_delete ON storage.objects;\n\nCREATE POLICY plg_inventory_images_read ON storage.objects\n FOR SELECT TO anon, authenticated\n USING (bucket_id = 'inventory-images');\n\nCREATE POLICY plg_inventory_images_insert ON storage.objects\n FOR INSERT TO authenticated\n WITH CHECK (\n bucket_id = 'inventory-images'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_inventory_images_update ON storage.objects\n FOR UPDATE TO authenticated\n USING (\n bucket_id = 'inventory-images'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n )\n WITH CHECK (\n bucket_id = 'inventory-images'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE POLICY plg_inventory_images_delete ON storage.objects\n FOR DELETE TO authenticated\n USING (\n bucket_id = 'inventory-images'\n AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)\n );\n\nCREATE OR REPLACE FUNCTION public.inventory_register_product_image(\n p_product uuid,\n p_storage_path text,\n p_public_url text,\n p_file_name text,\n p_file_size integer,\n p_mime_type text\n) RETURNS uuid\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = '' 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\nREVOKE ALL ON FUNCTION public.inventory_register_product_image(uuid, text, text, text, integer, text)\n FROM public, anon;\nGRANT EXECUTE ON FUNCTION public.inventory_register_product_image(uuid, text, text, text, integer, text)\n TO authenticated, service_role;\n";
25
- export declare const MIGRATION_024_RESTAURANT_ORDER_STOCK_EVENTS = "-- ---------------------------------------------------------------------------\n-- 024_restaurant_order_stock_events.sql\n--\n-- The durable order event is the business-effect boundary. A completed\n-- restaurant order consumes either the active recipe behind the sold product\n-- or, when there is no recipe, the sold product itself. The path keeps the historical\n-- `order_item:<id>` key, so pools that briefly ran the browser callback cannot\n-- consume the same line twice while converging on the event log.\n--\n-- A cancelled sale never reaches `completed`, so it creates no stock movement.\n-- Post-completion returns/refunds are a different business fact and deliberately\n-- do not weaken the core order state machine, where `completed` is terminal.\n-- ---------------------------------------------------------------------------\n\nCREATE OR REPLACE FUNCTION app.inventory_consume_order_item(\n p_tenant uuid,\n p_order uuid,\n p_order_item uuid,\n p_unit uuid,\n p_product uuid,\n p_quantity numeric,\n p_actor uuid DEFAULT NULL\n) RETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\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\nREVOKE ALL ON FUNCTION app.inventory_consume_order_item(uuid, uuid, uuid, uuid, uuid, numeric, uuid)\n FROM public, anon, authenticated;\n\nCREATE OR REPLACE FUNCTION public.plg_inventory_on_order_completed(p_event jsonb)\nRETURNS jsonb\nLANGUAGE plpgsql SECURITY DEFINER SET search_path = ''\nAS $$\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;\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.order_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.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 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 'results', v_results);\nEND $$;\n\nCOMMENT ON FUNCTION public.plg_inventory_on_order_completed(jsonb) IS\n 'Consumes a completed restaurant order from the durable event log. Active recipes expand into ingredients; products without recipes consume their own stock. The order-item key is compatible with the retired browser bridge.';\n\nREVOKE ALL ON FUNCTION public.plg_inventory_on_order_completed(jsonb)\n FROM public, anon, authenticated;\nGRANT EXECUTE ON FUNCTION public.plg_inventory_on_order_completed(jsonb) TO service_role;\n\nSELECT public.register_event_subscription(\n 'inventory', 'order.completed', 'plg_inventory_on_order_completed',\n 'A completed restaurant order consumes its active recipe or the sold product', 5\n);\n";
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
+ 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
+ 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";
26
4
  export declare const MIGRATIONS: Array<{
27
5
  id: string;
28
6
  sql: string;