bmc_hub/migrations/242_economic_product_integration.sql

153 lines
10 KiB
MySQL
Raw Normal View History

-- 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;