-- Additive product integration; no automatic legacy-number backfill. BEGIN; CREATE TABLE IF NOT EXISTS economic_catalog_connections ( id SERIAL PRIMARY KEY, agreement_number TEXT NOT NULL UNIQUE, name TEXT NOT NULL, currency VARCHAR(3) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT false, auto_sync BOOLEAN NOT NULL DEFAULT false, sync_interval_minutes INTEGER NOT NULL DEFAULT 15 CHECK(sync_interval_minutes >= 5), next_sync_at TIMESTAMPTZ, last_synced_at TIMESTAMPTZ, number_prefix VARCHAR(10) NOT NULL DEFAULT 'BMC-', next_number BIGINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE economic_catalog_connections ADD COLUMN IF NOT EXISTS time_unit_number INTEGER; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_connection_id INTEGER REFERENCES economic_catalog_connections(id); ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_product_number VARCHAR(25); ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_product_group_number INTEGER; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_sales_price_reference NUMERIC(18,4); ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_currency VARCHAR(3); ALTER TABLE products ADD COLUMN IF NOT EXISTS sales_currency VARCHAR(3) NOT NULL DEFAULT 'DKK'; -- Legacy cost currency is unknown until explicitly verified; do not assume DKK. ALTER TABLE products ADD COLUMN IF NOT EXISTS cost_currency VARCHAR(3); ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_unit_number INTEGER; ALTER TABLE products ADD COLUMN IF NOT EXISTS is_active_in_economic BOOLEAN; ALTER TABLE products ADD COLUMN IF NOT EXISTS lifecycle_status TEXT NOT NULL DEFAULT 'active'; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_sync_status TEXT NOT NULL DEFAULT 'unlinked'; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_snapshot JSONB; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_last_synced_at TIMESTAMPTZ; ALTER TABLE products ADD COLUMN IF NOT EXISTS economic_name_version INTEGER NOT NULL DEFAULT 0; ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS variant_key TEXT; ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS license_term TEXT; ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS billing_interval TEXT; ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS market TEXT; ALTER TABLE product_suppliers ADD COLUMN IF NOT EXISTS valid_until DATE; -- Existing offers are not deduplicated or rewritten. New identified offers cannot collide. CREATE UNIQUE INDEX IF NOT EXISTS product_supplier_variant_identity ON product_suppliers (product_id,COALESCE(supplier_code,supplier_name,''),supplier_sku,variant_key, COALESCE(license_term,''),COALESCE(billing_interval,''),COALESCE(market,'')) WHERE variant_key IS NOT NULL AND supplier_sku IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS products_economic_identity ON products(economic_connection_id,economic_product_number) WHERE economic_product_number IS NOT NULL; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='products_economic_identity_pair') THEN ALTER TABLE products ADD CONSTRAINT products_economic_identity_pair CHECK ((economic_connection_id IS NULL) = (economic_product_number IS NULL)); END IF; END $$; CREATE TABLE IF NOT EXISTS economic_catalog_references ( connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id), kind TEXT NOT NULL CHECK(kind IN ('product-groups','units')), number INTEGER NOT NULL, name TEXT NOT NULL, payload JSONB NOT NULL, category_id INTEGER, allow_new BOOLEAN NOT NULL DEFAULT true, phasing_out BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY(connection_id,kind,number) ); CREATE TABLE IF NOT EXISTS economic_catalog_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id), product_id INTEGER REFERENCES products(id), kind TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', version INTEGER, payload JSONB NOT NULL DEFAULT '{}', result JSONB NOT NULL DEFAULT '{}', error TEXT, attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), lease_until TIMESTAMPTZ, created_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_name_version ON economic_catalog_jobs(product_id,version) WHERE kind='name'; CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_single_import ON economic_catalog_jobs(connection_id) WHERE kind='import' AND status IN ('pending','running','retry'); CREATE UNIQUE INDEX IF NOT EXISTS economic_catalog_single_create ON economic_catalog_jobs(product_id) WHERE kind='create' AND status NOT IN ('failed','cancelled'); CREATE TABLE IF NOT EXISTS economic_catalog_import_pages ( job_id UUID NOT NULL REFERENCES economic_catalog_jobs(id), page_key TEXT NOT NULL, ordinal INTEGER NOT NULL, payload JSONB NOT NULL, next_page TEXT, PRIMARY KEY(job_id,page_key), UNIQUE(job_id,ordinal) ); CREATE TABLE IF NOT EXISTS economic_price_rules ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, product_id INTEGER REFERENCES products(id), customer_id INTEGER REFERENCES customers(id), category_id INTEGER, kind TEXT NOT NULL CHECK(kind IN ('fixed','discount','cost_markup')), value NUMERIC(18,4) NOT NULL CHECK(value >= 0), currency VARCHAR(3) NOT NULL DEFAULT 'DKK', priority INTEGER NOT NULL DEFAULT 0, valid_from DATE, valid_to DATE, active BOOLEAN NOT NULL DEFAULT true, version INTEGER NOT NULL DEFAULT 1, created_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK(valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from), CHECK(kind <> 'discount' OR value <= 100) ); CREATE TABLE IF NOT EXISTS economic_document_exports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), connection_id INTEGER NOT NULL REFERENCES economic_catalog_connections(id), document_key TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('order','invoice')), status TEXT NOT NULL DEFAULT 'pending', snapshot JSONB NOT NULL, response JSONB, economic_number TEXT, error TEXT, created_by INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(connection_id,kind,document_key) ); CREATE TABLE IF NOT EXISTS economic_integration_audit ( id BIGSERIAL PRIMARY KEY, object_type TEXT NOT NULL, object_id TEXT NOT NULL, action TEXT NOT NULL, user_id INTEGER, changes JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE OR REPLACE FUNCTION protect_exported_ordre_lines() RETURNS trigger AS $$ BEGIN IF (NEW.lines_json IS DISTINCT FROM OLD.lines_json OR NEW.customer_id IS DISTINCT FROM OLD.customer_id) AND EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='order-draft:'||OLD.id::text AND status NOT IN ('rejected','archived')) THEN RAISE EXCEPTION 'Exported or uncertain document lines are immutable; create a new draft'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS protect_exported_ordre_lines ON ordre_drafts; CREATE TRIGGER protect_exported_ordre_lines BEFORE UPDATE ON ordre_drafts FOR EACH ROW EXECUTE FUNCTION protect_exported_ordre_lines(); CREATE OR REPLACE FUNCTION protect_exported_time_lines() RETURNS trigger AS $$ DECLARE target_id INTEGER; BEGIN target_id := CASE WHEN TG_OP='INSERT' THEN NEW.order_id ELSE OLD.order_id END; IF EXISTS (SELECT 1 FROM economic_document_exports WHERE document_key='tmodule-order:'||target_id::text AND status NOT IN ('rejected','archived')) THEN RAISE EXCEPTION 'Exported or uncertain time document lines are immutable'; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS protect_exported_time_lines ON tmodule_order_lines; CREATE TRIGGER protect_exported_time_lines BEFORE INSERT OR UPDATE OR DELETE ON tmodule_order_lines FOR EACH ROW EXECUTE FUNCTION protect_exported_time_lines(); CREATE OR REPLACE FUNCTION protect_economic_product_identity() RETURNS trigger AS $$ BEGIN IF TG_OP='DELETE' AND OLD.economic_product_number IS NOT NULL THEN RAISE EXCEPTION 'Linked products must be deactivated, not deleted'; END IF; IF TG_OP='UPDATE' AND OLD.economic_product_number IS NOT NULL AND (NEW.economic_product_number IS DISTINCT FROM OLD.economic_product_number OR NEW.economic_connection_id IS DISTINCT FROM OLD.economic_connection_id) AND current_setting('bmc.economic_rebind', true) IS DISTINCT FROM OLD.id::text THEN RAISE EXCEPTION 'The e-conomic product identity is permanent'; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS protect_economic_product_identity ON products; CREATE TRIGGER protect_economic_product_identity BEFORE UPDATE OR DELETE ON products FOR EACH ROW EXECUTE FUNCTION protect_economic_product_identity(); -- Names stay in Hub. We record the local change but never PUT it to e-conomic. CREATE OR REPLACE FUNCTION queue_economic_product_name() RETURNS trigger AS $$ BEGIN IF NEW.economic_product_number IS NOT NULL AND NEW.name IS DISTINCT FROM OLD.name THEN NEW.economic_name_version := OLD.economic_name_version + 1; INSERT INTO product_audit_log(product_id,event_type,user_id,changes) VALUES(NEW.id,'economic_name_queued',NEW.updated_by,jsonb_build_object('old',OLD.name,'new',NEW.name,'version',NEW.economic_name_version)); IF NEW.name IS DISTINCT FROM NEW.economic_snapshot->>'name' THEN NEW.economic_sync_status := 'local_only'; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS queue_economic_product_name ON products; CREATE TRIGGER queue_economic_product_name BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION queue_economic_product_name(); INSERT INTO permissions(code,description,category) VALUES ('economic.catalog.view','Se e-conomic vareintegration','economic'), ('economic.catalog.manage','Administrere e-conomic varer og synkronisering','economic'), ('economic.catalog.rebind','Administrativ omkobling af permanent varereference','economic'), ('economic.pricing.manage','Administrere prisregler','economic'), ('economic.pricing.override','Overskrive beregnet pris','economic'), ('economic.cost.view','Se kost og dækningsbidrag','economic'), ('economic.documents.export','Eksportere økonomiske dokumenter','economic') ON CONFLICT(code) DO NOTHING; COMMIT;