- Added migration to create economic_change_requests table for tracking change requests for customers and products. - Introduced new permissions for requesting and approving changes in e-conomic. - Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses. - Created browser tests to validate UI interactions related to product management and change requests. - Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies.
153 lines
10 KiB
PL/PgSQL
153 lines
10 KiB
PL/PgSQL
-- 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;
|