* feat(sales-orders): kundorder with partial delivery and partial invoicing Adds sales orders (kundorder) as their own non-ledger document between agreement and invoice, for companies that deliver or invoice in parts. Schema (20260902130000): sales_orders + sales_order_items with RLS via user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon execute), company_settings.sales_orders_enabled UI gate, and back-links invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced quantity per order line is DERIVED from the linked invoice lines on non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so no counter can drift and a credited invoice frees its quantity. Header status is draft / confirmed / completed / cancelled; completion is kept by DB triggers from the same derived quantity. Delivery and invoicing progress are derived per line, never stored as status. Service + API: lib/sales-orders (create/update with id-preserving line replace, transitions with compare-and-set, cumulative delivery registration, invoice-from-order through buildInvoiceWriteData so booking stays in the engine, proforma -> order conversion), routes under /api/sales-orders and /api/invoices/[id]/convert-to-order, structured SALES_ORDER_* error codes, archive classification of the new tables. The invoice editor round-trips sales_order_item_id so a draft edit cannot drop the link; GET /api/invoices gains ?sales_order_id=. UI: /sales-orders list, create/edit form reusing the invoice line conventions, detail with deliver and create-invoice dialogs and linked invoices; nav row behind the settings toggle; the webshop row is relabelled webshop_orders; "Skapa order" on proformas. MCP (20260902141000/141001): list/get reads plus four staged writes (create, transition, register delivery, create invoice from order) whose executors call the lib services; op types added to the pending operations CHECK. Tests: route tests for every route (401/400/404/happy), service unit tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts (16 cases, green on staging) covering RLS, numbering guards, the over-invoice trigger incl. release on cancel/credit and cross-company refusal, the quantity floor, and completion maintenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQW7mXvbAPgjUHq7dSEamr * fix(sales-orders): harden kundorder after skeptic and security review Resolves every finding from the PR #2166 review pass in one batch. Order link integrity: replaceInvoiceItems now refuses a line set that drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK), closing the MCP update_invoice header-only edit and the v1 PATCH path that severed the link and freed the quantity for double invoicing. The update_invoice re-fetch, gnubok_get_invoice and the v1 item projection now carry sales_order_item_id so well-behaved clients round-trip it. Quantity math: derived remaining/invoiced quantities are rounded to six decimals and compared with an epsilon (roundQty, qtyGreater) so a float remainder such as 0.5999999999999996 can neither refuse the final partial invoice nor land as an invoice quantity; duplicate explicit picks are summed before validation. Leveransdatum: per-line last_delivery_date (migration 20260902160000); an invoice takes the latest date over the lines it covers and only when the covered quantity was delivered, never the header date and never for an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23). VAT drift: the order stores the customer type and VAT-validation flag its lines were priced under; invoicing refuses with SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the order re-validates the lines. Customer and currency are frozen once invoices exist. Tenant and role gates: composite FK (sales_order_id, company_id) ties a line to its parent's company (Superagent P2); aa_enforce_company_writer_role on both tables so a viewer cannot write through the browser client. Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines instead of dropping those fields. RESTRICT FK errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES. Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with reason), regenerated skills/accounted-api (sales_order_item_id on invoice items), pg tests for the composite FK, the viewer gate and the new columns, unit tests for every changed path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): resolve CodeRabbit round on PR #2166 Quick wins from the review, all in one pass: - replaceInvoiceItems fails closed when the invoice_items snapshot cannot be read (it is both the restore source and the input to the kundorder link guard); the guard branch is explicit in both PATCH routes. - Cumulative delivery registration carries an optimistic predicate on the quantity it read, so two concurrent registrations cannot regress each other; DELETE of an order keeps its allowed status in the predicate and answers a conflict when zero rows match. - Business dates (order date, delivery date, invoice date) default to the Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the delivery date is also the Riksbanken rate anchor. - The invoice-from-order executor treats an event emit failure as non-blocking: the draft already exists. - sales_order_items are archived through their parent with the order currency denormalised, like invoice_items. - Proforma "Skapa order" tolerates a 2xx without a parsable body; the settings toggle refreshes the server-rendered nav. - List route doc states that q matches the order number (customer names are matched client-side). Declined (out of scope for this PR): moving header + line writes and the delivery loop into transactional RPCs (same PostgREST pattern as the invoice PATCH path, tracked as a follow-up), the MCP approval handler's error message shape (pre-existing code outside this change), and the docstring-coverage warning (no repo convention). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling - 20260902160000_sales_orders_hardening.sql collided with main's 20260902160000_parties_substrate.sql after the third sync; renamed to 20260902180000 and made idempotent (DROP ... IF EXISTS before each ADD CONSTRAINT) so a preview branch that applied it under the old version replays it cleanly. Staging's schema_migrations row renamed. - sales_order_items goes back to a direct archive dump: the coverage contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a table with its own company_id; the currency lives on the parent order one file over, joined by sales_order_id. - Scanner ceiling re-baselined after merging main (parties phase 1): 397. - v1 PATCH test queues a real empty invoice_items snapshot now that replaceInvoiceItems fails closed on an unreadable one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): drop the composite FK before its unique index on replay The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped the unique (id, company_id) before the FK that depends on its index, so the preview branch replay (which had applied the file under its former version) failed with SQLSTATE 2BP01. Order swapped; replay verified on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
551 lines
24 KiB
PL/PgSQL
551 lines
24 KiB
PL/PgSQL
-- Kundorder (sales orders): the document between "agreed" and "faktura".
|
|
--
|
|
-- A sales order is a NON-ledger document: it never books, carries no BFL
|
|
-- sequence or immutability obligation, and exists so a company that delivers
|
|
-- or invoices in parts has one place that says what was ordered, what has
|
|
-- been delivered and what has been invoiced. Invoices created from an order
|
|
-- go through the ordinary invoice path (draft, F-number at finalize, booking
|
|
-- in the engine); the order only remembers which invoice lines came from
|
|
-- which order lines.
|
|
--
|
|
-- Design (kundorder plan, 2026-09-02):
|
|
-- * status is a four-state header machine: draft, confirmed, completed,
|
|
-- cancelled. Delivery state and invoicing state are two independent axes
|
|
-- DERIVED from line quantities, never stored as status values: an order
|
|
-- is normally both partially delivered and partially invoiced at once.
|
|
-- * invoiced quantity is NOT a stored counter. Every invoice line created
|
|
-- from an order line carries invoice_items.sales_order_item_id, and the
|
|
-- invoiced quantity is the sum over linked lines whose invoice is neither
|
|
-- cancelled nor credited. A BEFORE trigger on invoice_items locks the
|
|
-- order line and refuses over-invoicing, so a counter cannot drift and a
|
|
-- credited invoice automatically frees its quantity for re-invoicing.
|
|
-- * completion (confirmed <-> completed) is maintained by AFTER triggers on
|
|
-- invoice_items and invoices.status from the same derived quantity, so a
|
|
-- cancelled or deleted draft reopens the order without application code.
|
|
-- * delivered_qty IS stored (there is no delivery document to derive it
|
|
-- from); delivery registration is an explicit user action.
|
|
-- * no inventory. Articles stay a non-inventory register; lines freeze the
|
|
-- article's description/unit/price/vat/revenue_account like invoice_items.
|
|
--
|
|
-- Naming note: the existing /orders page and the `sales_orders` nav label
|
|
-- key belong to webshop_orders (store-sync mirror, service-role INSERT only).
|
|
-- This table is user-authored and lives at /sales-orders.
|
|
|
|
-- =============================================================================
|
|
-- 1. sales_orders
|
|
-- =============================================================================
|
|
CREATE TABLE public.sales_orders (
|
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
customer_id uuid REFERENCES public.customers(id) ON DELETE SET NULL,
|
|
order_number text,
|
|
status text NOT NULL DEFAULT 'draft'
|
|
CHECK (status IN ('draft', 'confirmed', 'completed', 'cancelled')),
|
|
-- The proforma this order was created from (proforma -> order conversion);
|
|
-- informational back-pointer only.
|
|
source_invoice_id uuid REFERENCES public.invoices(id) ON DELETE SET NULL,
|
|
order_date date NOT NULL DEFAULT CURRENT_DATE,
|
|
requested_delivery_date date,
|
|
-- Latest registered delivery date; becomes the invoice's delivery_date
|
|
-- (taxable event, ML 8 kap 21-23 §) when an invoice is created from the order.
|
|
last_delivery_date date,
|
|
currency text NOT NULL DEFAULT 'SEK' REFERENCES public.currencies(code),
|
|
subtotal numeric NOT NULL DEFAULT 0,
|
|
vat_amount numeric NOT NULL DEFAULT 0,
|
|
total numeric NOT NULL DEFAULT 0,
|
|
your_reference text,
|
|
our_reference text,
|
|
notes text,
|
|
-- Dimensions bag applied to every invoice created from the order.
|
|
default_dimensions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
confirmed_at timestamptz,
|
|
completed_at timestamptz,
|
|
cancelled_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
ALTER TABLE public.sales_orders ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "view own-company sales_orders"
|
|
ON public.sales_orders FOR SELECT USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "insert own-company sales_orders"
|
|
ON public.sales_orders FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "update own-company sales_orders"
|
|
ON public.sales_orders FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "delete own-company sales_orders"
|
|
ON public.sales_orders FOR DELETE USING (company_id IN (SELECT user_company_ids()));
|
|
|
|
CREATE INDEX idx_sales_orders_company_id ON public.sales_orders (company_id);
|
|
CREATE INDEX idx_sales_orders_company_status ON public.sales_orders (company_id, status, order_date DESC);
|
|
CREATE INDEX idx_sales_orders_customer_id ON public.sales_orders (customer_id);
|
|
CREATE INDEX idx_sales_orders_source_invoice_id ON public.sales_orders (source_invoice_id);
|
|
CREATE UNIQUE INDEX uq_sales_orders_company_number
|
|
ON public.sales_orders (company_id, order_number) WHERE order_number IS NOT NULL;
|
|
|
|
CREATE TRIGGER set_updated_at_sales_orders
|
|
BEFORE UPDATE ON public.sales_orders
|
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
CREATE TRIGGER audit_sales_orders
|
|
AFTER INSERT OR UPDATE OR DELETE ON public.sales_orders
|
|
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
|
|
|
-- =============================================================================
|
|
-- 2. sales_order_items
|
|
-- =============================================================================
|
|
CREATE TABLE public.sales_order_items (
|
|
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
-- Own company_id (defense in depth alongside the parent join): the
|
|
-- over-invoice trigger compares it against the invoice's company.
|
|
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
|
sales_order_id uuid NOT NULL REFERENCES public.sales_orders(id) ON DELETE CASCADE,
|
|
sort_order integer NOT NULL DEFAULT 0,
|
|
line_type text NOT NULL DEFAULT 'product' CHECK (line_type IN ('product', 'text')),
|
|
description text NOT NULL DEFAULT '',
|
|
quantity numeric NOT NULL DEFAULT 1 CHECK (quantity >= 0),
|
|
delivered_qty numeric NOT NULL DEFAULT 0 CHECK (delivered_qty >= 0),
|
|
unit text NOT NULL DEFAULT 'st',
|
|
unit_price numeric NOT NULL DEFAULT 0,
|
|
discount_percent numeric NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
|
vat_rate numeric NOT NULL DEFAULT 25,
|
|
-- NET of discount, order currency (same formula as invoice_items.line_total).
|
|
line_total numeric NOT NULL DEFAULT 0,
|
|
article_id uuid REFERENCES public.articles(id) ON DELETE SET NULL,
|
|
-- Frozen copy of the article's posting-account override at line-create time.
|
|
revenue_account text,
|
|
dimensions jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
CONSTRAINT sales_order_items_delivered_within_ordered CHECK (delivered_qty <= quantity)
|
|
);
|
|
|
|
ALTER TABLE public.sales_order_items ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY "view own-company sales_order_items"
|
|
ON public.sales_order_items FOR SELECT USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "insert own-company sales_order_items"
|
|
ON public.sales_order_items FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "update own-company sales_order_items"
|
|
ON public.sales_order_items FOR UPDATE USING (company_id IN (SELECT user_company_ids()));
|
|
CREATE POLICY "delete own-company sales_order_items"
|
|
ON public.sales_order_items FOR DELETE USING (company_id IN (SELECT user_company_ids()));
|
|
|
|
CREATE INDEX idx_sales_order_items_order ON public.sales_order_items (sales_order_id, sort_order);
|
|
CREATE INDEX idx_sales_order_items_company_id ON public.sales_order_items (company_id);
|
|
CREATE INDEX idx_sales_order_items_article_id ON public.sales_order_items (article_id);
|
|
|
|
CREATE TRIGGER set_updated_at_sales_order_items
|
|
BEFORE UPDATE ON public.sales_order_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
|
|
|
-- =============================================================================
|
|
-- 3. company_settings: module toggle + per-company order-number counter
|
|
-- =============================================================================
|
|
-- sales_orders_enabled is a UI-visibility gate only (same contract as
|
|
-- dimensions_enabled / mileage_enabled): the pages and APIs work regardless.
|
|
ALTER TABLE public.company_settings
|
|
ADD COLUMN IF NOT EXISTS sales_orders_enabled boolean NOT NULL DEFAULT false;
|
|
ALTER TABLE public.company_settings
|
|
ADD COLUMN IF NOT EXISTS next_sales_order_number integer NOT NULL DEFAULT 1;
|
|
|
|
-- =============================================================================
|
|
-- 4. invoices / invoice_items back-links
|
|
-- =============================================================================
|
|
-- RESTRICT on both: an order with invoices can be cancelled but never
|
|
-- deleted, so the invoice's provenance survives (the invoice is
|
|
-- räkenskapsinformation; its source order is context a revisor may ask for).
|
|
ALTER TABLE public.invoices
|
|
ADD COLUMN IF NOT EXISTS sales_order_id uuid REFERENCES public.sales_orders(id) ON DELETE RESTRICT;
|
|
CREATE INDEX IF NOT EXISTS idx_invoices_sales_order_id
|
|
ON public.invoices (sales_order_id) WHERE sales_order_id IS NOT NULL;
|
|
|
|
ALTER TABLE public.invoice_items
|
|
ADD COLUMN IF NOT EXISTS sales_order_item_id uuid REFERENCES public.sales_order_items(id) ON DELETE RESTRICT;
|
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_sales_order_item_id
|
|
ON public.invoice_items (sales_order_item_id) WHERE sales_order_item_id IS NOT NULL;
|
|
|
|
-- =============================================================================
|
|
-- 5. generate_sales_order_number RPC: atomic + idempotent
|
|
-- (clone of generate_article_number incl. the 20260901100000 hardening:
|
|
-- membership check, empty search_path, no anon/PUBLIC execute).
|
|
-- Orders are not verifikationer, so gaps are legally irrelevant; the
|
|
-- number is allocated at creation.
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.generate_sales_order_number(
|
|
p_company_id uuid,
|
|
p_order_id uuid
|
|
)
|
|
RETURNS text
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
DECLARE
|
|
v_existing text;
|
|
v_number integer;
|
|
v_final text;
|
|
v_trusted boolean;
|
|
BEGIN
|
|
-- Same fail-closed gate as generate_article_number after 20260901100000:
|
|
-- a JWT with a role but no sub (the anon key) is refused; a direct DB
|
|
-- connection with no claims at all (migrations, pg tests) is trusted.
|
|
v_trusted := auth.uid() IS NULL
|
|
AND (
|
|
COALESCE(auth.role(), '') = 'service_role'
|
|
OR (auth.role() IS NULL AND session_user <> 'authenticator')
|
|
);
|
|
IF NOT v_trusted AND NOT EXISTS (
|
|
SELECT 1 FROM public.company_members
|
|
WHERE user_id = auth.uid() AND company_id = p_company_id
|
|
) THEN
|
|
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT order_number INTO v_existing
|
|
FROM public.sales_orders
|
|
WHERE id = p_order_id AND company_id = p_company_id
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'Sales order % not found in company %', p_order_id, p_company_id;
|
|
END IF;
|
|
|
|
IF v_existing IS NOT NULL THEN
|
|
RETURN v_existing;
|
|
END IF;
|
|
|
|
UPDATE public.company_settings
|
|
SET next_sales_order_number = next_sales_order_number + 1,
|
|
updated_at = now()
|
|
WHERE company_id = p_company_id
|
|
RETURNING next_sales_order_number - 1
|
|
INTO v_number;
|
|
|
|
IF v_number IS NULL THEN
|
|
RAISE EXCEPTION 'Company settings not found for company %', p_company_id;
|
|
END IF;
|
|
|
|
v_final := 'OR-' || v_number::text;
|
|
|
|
UPDATE public.sales_orders
|
|
SET order_number = v_final
|
|
WHERE id = p_order_id AND company_id = p_company_id;
|
|
|
|
RETURN v_final;
|
|
END;
|
|
$function$;
|
|
|
|
REVOKE EXECUTE ON FUNCTION public.generate_sales_order_number(uuid, uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.generate_sales_order_number(uuid, uuid) TO authenticated, service_role;
|
|
|
|
-- =============================================================================
|
|
-- 6. Derived invoiced quantity (read side, SECURITY INVOKER: RLS applies)
|
|
-- =============================================================================
|
|
-- One row per order line with the quantity already invoiced. Linked lines on
|
|
-- cancelled or credited invoices do not count, so a makulerad draft or a
|
|
-- fully credited invoice frees the quantity again. Credit notes never link
|
|
-- to order lines (they reference the invoice they credit), but the
|
|
-- credited_invoice_id guard keeps a hand-linked one from netting a line.
|
|
CREATE OR REPLACE FUNCTION public.sales_order_invoiced_quantities(p_order_ids uuid[])
|
|
RETURNS TABLE (sales_order_id uuid, sales_order_item_id uuid, invoiced_qty numeric)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SET search_path = ''
|
|
AS $$
|
|
SELECT soi.sales_order_id,
|
|
soi.id AS sales_order_item_id,
|
|
COALESCE(SUM(ii.quantity) FILTER (
|
|
WHERE i.id IS NOT NULL
|
|
AND i.status NOT IN ('cancelled', 'credited')
|
|
AND i.credited_invoice_id IS NULL
|
|
), 0) AS invoiced_qty
|
|
FROM public.sales_order_items soi
|
|
LEFT JOIN public.invoice_items ii ON ii.sales_order_item_id = soi.id
|
|
LEFT JOIN public.invoices i ON i.id = ii.invoice_id
|
|
WHERE soi.sales_order_id = ANY (p_order_ids)
|
|
GROUP BY soi.sales_order_id, soi.id
|
|
$$;
|
|
|
|
REVOKE EXECUTE ON FUNCTION public.sales_order_invoiced_quantities(uuid[]) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.sales_order_invoiced_quantities(uuid[]) TO authenticated, service_role;
|
|
|
|
-- =============================================================================
|
|
-- 7. Over-invoicing guard on invoice_items (BEFORE INSERT/UPDATE)
|
|
-- =============================================================================
|
|
-- Locks the order line so two concurrent invoice creations serialize, then
|
|
-- refuses a linked line whose quantity would push the invoiced total past the
|
|
-- ordered quantity. SECURITY DEFINER because the sum must see every linked
|
|
-- line regardless of the caller's RLS view; the company check keeps a line
|
|
-- from linking across tenants.
|
|
CREATE OR REPLACE FUNCTION public.enforce_sales_order_item_invoiced_qty()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
DECLARE
|
|
v_ordered numeric;
|
|
v_item_company uuid;
|
|
v_invoice_company uuid;
|
|
v_invoice_status text;
|
|
v_other numeric;
|
|
BEGIN
|
|
IF NEW.sales_order_item_id IS NULL THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
SELECT soi.quantity, soi.company_id
|
|
INTO v_ordered, v_item_company
|
|
FROM public.sales_order_items soi
|
|
WHERE soi.id = NEW.sales_order_item_id
|
|
FOR UPDATE;
|
|
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'SALES_ORDER_ITEM_NOT_FOUND: order line % does not exist', NEW.sales_order_item_id
|
|
USING ERRCODE = 'foreign_key_violation';
|
|
END IF;
|
|
|
|
SELECT i.company_id, i.status
|
|
INTO v_invoice_company, v_invoice_status
|
|
FROM public.invoices i
|
|
WHERE i.id = NEW.invoice_id;
|
|
|
|
IF v_invoice_company IS DISTINCT FROM v_item_company THEN
|
|
RAISE EXCEPTION 'SALES_ORDER_ITEM_COMPANY_MISMATCH: invoice and order line belong to different companies'
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
|
|
-- A line on an already cancelled/credited invoice does not count and is
|
|
-- not counted against; nothing to enforce.
|
|
IF v_invoice_status IN ('cancelled', 'credited') THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
SELECT COALESCE(SUM(ii.quantity), 0)
|
|
INTO v_other
|
|
FROM public.invoice_items ii
|
|
JOIN public.invoices i ON i.id = ii.invoice_id
|
|
WHERE ii.sales_order_item_id = NEW.sales_order_item_id
|
|
AND ii.id <> NEW.id
|
|
AND i.status NOT IN ('cancelled', 'credited')
|
|
AND i.credited_invoice_id IS NULL;
|
|
|
|
IF v_other + NEW.quantity > v_ordered THEN
|
|
RAISE EXCEPTION 'SALES_ORDER_OVER_INVOICED: order line % has % of % already invoiced, cannot add %',
|
|
NEW.sales_order_item_id, v_other, v_ordered, NEW.quantity
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$function$;
|
|
|
|
CREATE TRIGGER enforce_sales_order_item_invoiced_qty
|
|
BEFORE INSERT OR UPDATE OF quantity, sales_order_item_id, invoice_id ON public.invoice_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_sales_order_item_invoiced_qty();
|
|
|
|
-- =============================================================================
|
|
-- 8. Order line guards: quantity never below what is invoiced; a line with
|
|
-- linked invoice lines cannot be deleted (RESTRICT FK already does that).
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.enforce_sales_order_item_quantity_floor()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
DECLARE
|
|
v_invoiced numeric;
|
|
BEGIN
|
|
IF NEW.quantity >= OLD.quantity THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
SELECT COALESCE(SUM(ii.quantity), 0)
|
|
INTO v_invoiced
|
|
FROM public.invoice_items ii
|
|
JOIN public.invoices i ON i.id = ii.invoice_id
|
|
WHERE ii.sales_order_item_id = NEW.id
|
|
AND i.status NOT IN ('cancelled', 'credited')
|
|
AND i.credited_invoice_id IS NULL;
|
|
|
|
IF NEW.quantity < v_invoiced THEN
|
|
RAISE EXCEPTION 'SALES_ORDER_QUANTITY_BELOW_INVOICED: order line % has % invoiced, cannot reduce to %',
|
|
NEW.id, v_invoiced, NEW.quantity
|
|
USING ERRCODE = 'check_violation';
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$function$;
|
|
|
|
CREATE TRIGGER enforce_sales_order_item_quantity_floor
|
|
BEFORE UPDATE OF quantity ON public.sales_order_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.enforce_sales_order_item_quantity_floor();
|
|
|
|
-- =============================================================================
|
|
-- 9. Completion maintenance: confirmed <-> completed from derived quantities
|
|
-- =============================================================================
|
|
-- An order is completed when it has at least one product line with a
|
|
-- positive quantity and every such line is fully invoiced. Runs after any
|
|
-- change to a linked invoice line or to a linked invoice's status, so
|
|
-- makulering / crediting / draft deletion reopens the order. Draft and
|
|
-- cancelled orders are never touched.
|
|
CREATE OR REPLACE FUNCTION public.refresh_sales_order_completion(p_order_id uuid)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
DECLARE
|
|
v_company uuid;
|
|
v_trusted boolean;
|
|
v_has_lines boolean;
|
|
v_open_lines boolean;
|
|
v_complete boolean;
|
|
BEGIN
|
|
IF p_order_id IS NULL THEN
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Callable by authenticated (the confirm transition invokes it directly),
|
|
-- so it carries the same fail-closed membership gate as the numbering
|
|
-- RPCs. Trigger invocations run under the DML user's claims (a member's)
|
|
-- or under a trusted no-claims connection.
|
|
SELECT company_id INTO v_company FROM public.sales_orders WHERE id = p_order_id;
|
|
IF v_company IS NULL THEN
|
|
RETURN;
|
|
END IF;
|
|
v_trusted := auth.uid() IS NULL
|
|
AND (
|
|
COALESCE(auth.role(), '') = 'service_role'
|
|
OR (auth.role() IS NULL AND session_user <> 'authenticator')
|
|
);
|
|
IF NOT v_trusted AND NOT EXISTS (
|
|
SELECT 1 FROM public.company_members
|
|
WHERE user_id = auth.uid() AND company_id = v_company
|
|
) THEN
|
|
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', v_company
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM public.sales_order_items soi
|
|
WHERE soi.sales_order_id = p_order_id AND soi.line_type = 'product' AND soi.quantity > 0
|
|
) INTO v_has_lines;
|
|
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM public.sales_order_items soi
|
|
WHERE soi.sales_order_id = p_order_id
|
|
AND soi.line_type = 'product'
|
|
AND soi.quantity > 0
|
|
AND soi.quantity > (
|
|
SELECT COALESCE(SUM(ii.quantity), 0)
|
|
FROM public.invoice_items ii
|
|
JOIN public.invoices i ON i.id = ii.invoice_id
|
|
WHERE ii.sales_order_item_id = soi.id
|
|
AND i.status NOT IN ('cancelled', 'credited')
|
|
AND i.credited_invoice_id IS NULL
|
|
)
|
|
) INTO v_open_lines;
|
|
|
|
v_complete := v_has_lines AND NOT v_open_lines;
|
|
|
|
UPDATE public.sales_orders so
|
|
SET status = CASE WHEN v_complete THEN 'completed' ELSE 'confirmed' END,
|
|
completed_at = CASE WHEN v_complete THEN COALESCE(so.completed_at, now()) ELSE NULL END
|
|
WHERE so.id = p_order_id
|
|
AND so.status IN ('confirmed', 'completed')
|
|
AND so.status IS DISTINCT FROM (CASE WHEN v_complete THEN 'completed' ELSE 'confirmed' END);
|
|
END;
|
|
$function$;
|
|
|
|
REVOKE EXECUTE ON FUNCTION public.refresh_sales_order_completion(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.refresh_sales_order_completion(uuid) TO authenticated, service_role;
|
|
|
|
CREATE OR REPLACE FUNCTION public.sales_order_completion_from_invoice_items()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
DECLARE
|
|
v_item uuid;
|
|
v_order uuid;
|
|
BEGIN
|
|
v_item := COALESCE(NEW.sales_order_item_id, OLD.sales_order_item_id);
|
|
IF v_item IS NULL THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
SELECT sales_order_id INTO v_order FROM public.sales_order_items WHERE id = v_item;
|
|
PERFORM public.refresh_sales_order_completion(v_order);
|
|
-- A re-link from one order line to another refreshes both orders.
|
|
IF TG_OP = 'UPDATE' AND NEW.sales_order_item_id IS DISTINCT FROM OLD.sales_order_item_id
|
|
AND OLD.sales_order_item_id IS NOT NULL THEN
|
|
SELECT sales_order_id INTO v_order FROM public.sales_order_items WHERE id = OLD.sales_order_item_id;
|
|
PERFORM public.refresh_sales_order_completion(v_order);
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$function$;
|
|
|
|
CREATE TRIGGER sales_order_completion_from_invoice_items
|
|
AFTER INSERT OR UPDATE OR DELETE ON public.invoice_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.sales_order_completion_from_invoice_items();
|
|
|
|
CREATE OR REPLACE FUNCTION public.sales_order_completion_from_invoice_status()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
BEGIN
|
|
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
|
-- Resolve the affected orders through the LINE links, not the header
|
|
-- back-pointer, so a hand-linked line on an invoice without
|
|
-- sales_order_id still keeps its order's completion honest.
|
|
PERFORM public.refresh_sales_order_completion(o.sales_order_id)
|
|
FROM (
|
|
SELECT DISTINCT soi.sales_order_id
|
|
FROM public.invoice_items ii
|
|
JOIN public.sales_order_items soi ON soi.id = ii.sales_order_item_id
|
|
WHERE ii.invoice_id = NEW.id
|
|
) o;
|
|
END IF;
|
|
RETURN NULL;
|
|
END;
|
|
$function$;
|
|
|
|
CREATE TRIGGER sales_order_completion_from_invoice_status
|
|
AFTER UPDATE OF status ON public.invoices
|
|
FOR EACH ROW EXECUTE FUNCTION public.sales_order_completion_from_invoice_status();
|
|
|
|
-- Confirming an order whose lines are already fully invoiced (hand-linked
|
|
-- lines) or changing line quantities also re-evaluates completion.
|
|
CREATE OR REPLACE FUNCTION public.sales_order_completion_from_order_items()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $function$
|
|
BEGIN
|
|
PERFORM public.refresh_sales_order_completion(COALESCE(NEW.sales_order_id, OLD.sales_order_id));
|
|
RETURN NULL;
|
|
END;
|
|
$function$;
|
|
|
|
CREATE TRIGGER sales_order_completion_from_order_items
|
|
AFTER INSERT OR UPDATE OF quantity, line_type OR DELETE ON public.sales_order_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.sales_order_completion_from_order_items();
|
|
|
|
COMMENT ON TABLE public.sales_orders IS
|
|
'Kundorder: non-ledger sales document between quote/agreement and invoice. Never books; invoices created from it go through the normal invoice path.';
|
|
COMMENT ON COLUMN public.invoice_items.sales_order_item_id IS
|
|
'Order line this invoice line was created from. The invoiced quantity of an order line is derived from these links (sales_order_invoiced_quantities); never stored.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|