bmc_hub/migrations/013_timetracking_module.sql

422 lines
18 KiB
MySQL
Raw Permalink Normal View History

-- ============================================================================
-- Migration 013: Tidsregistrering & Faktureringsmodul (Isoleret)
-- ============================================================================
-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data.
-- Alle tabeller har prefix 'tmodule_' for at markere tilhørsforhold til modulet.
-- Ved uninstall køres DROP-scriptet i bunden af denne fil.
-- ============================================================================
-- Metadata tabel til at tracke modulets tilstand
CREATE TABLE IF NOT EXISTS tmodule_metadata (
id SERIAL PRIMARY KEY,
module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
installed_by INTEGER, -- Reference til users.id (read-only, ingen FK)
last_sync_at TIMESTAMP,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb
);
-- Indsæt initial metadata
INSERT INTO tmodule_metadata (module_version, is_active)
VALUES ('1.0.0', true)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- KUNDE-CACHE (read-only kopi fra vTiger for isolation)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_customers (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger Account ID
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only)
hourly_rate DECIMAL(10,2), -- Kan override Hub-rate
uses_time_card BOOLEAN DEFAULT false, -- Klippekort - faktureres eksternt
vtiger_data JSONB, -- Original vTiger data for reference
sync_hash VARCHAR(64), -- SHA256 af data for change detection
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tmodule_customers_vtiger ON tmodule_customers(vtiger_id);
CREATE INDEX idx_tmodule_customers_hub ON tmodule_customers(hub_customer_id);
CREATE INDEX idx_tmodule_customers_synced ON tmodule_customers(last_synced_at);
-- ============================================================================
-- CASE-CACHE (read-only kopi fra vTiger HelpDesk/ProjectTask)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_cases (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger HelpDesk/ProjectTask ID
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50),
priority VARCHAR(50),
module_type VARCHAR(50), -- HelpDesk, ProjectTask, etc.
vtiger_data JSONB,
sync_hash VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tmodule_cases_vtiger ON tmodule_cases(vtiger_id);
CREATE INDEX idx_tmodule_cases_customer ON tmodule_cases(customer_id);
CREATE INDEX idx_tmodule_cases_status ON tmodule_cases(status);
CREATE INDEX idx_tmodule_cases_synced ON tmodule_cases(last_synced_at);
-- ============================================================================
-- TIDSREGISTRERINGER (read-only kopi fra vTiger ModComments)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_times (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger ModComments ID
case_id INTEGER NOT NULL REFERENCES tmodule_cases(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
-- Original vTiger data
description TEXT,
original_hours DECIMAL(5,2) NOT NULL,
worked_date DATE,
user_name VARCHAR(255), -- vTiger user (read-only)
-- Godkendelsesdata (ændres kun i modulet)
status VARCHAR(20) DEFAULT 'pending', -- pending|approved|rejected|billed
approved_hours DECIMAL(5,2),
rounded_to DECIMAL(3,1), -- 0.5, 1.0, etc.
approval_note TEXT,
billable BOOLEAN DEFAULT true,
approved_at TIMESTAMP,
approved_by INTEGER, -- Reference til users.id (read-only)
-- Metadata
vtiger_data JSONB,
sync_hash VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT check_hours_positive CHECK (original_hours > 0),
CONSTRAINT check_approved_hours CHECK (approved_hours IS NULL OR approved_hours > 0),
CONSTRAINT check_status CHECK (status IN ('pending', 'approved', 'rejected', 'billed'))
);
CREATE INDEX idx_tmodule_times_vtiger ON tmodule_times(vtiger_id);
CREATE INDEX idx_tmodule_times_case ON tmodule_times(case_id);
CREATE INDEX idx_tmodule_times_customer ON tmodule_times(customer_id);
CREATE INDEX idx_tmodule_times_status ON tmodule_times(status);
CREATE INDEX idx_tmodule_times_date ON tmodule_times(worked_date);
CREATE INDEX idx_tmodule_times_approved_by ON tmodule_times(approved_by);
-- ============================================================================
-- ORDRER (genereret fra godkendte tider)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
hub_customer_id INTEGER, -- Reference til customers.id (read-only)
-- Order metadata
order_number VARCHAR(50), -- Auto-generated: TT-YYYYMMDD-XXX
order_date DATE DEFAULT CURRENT_DATE,
status VARCHAR(20) DEFAULT 'draft', -- draft|exported|sent|cancelled
-- Beløb
total_hours DECIMAL(8,2) NOT NULL DEFAULT 0,
hourly_rate DECIMAL(10,2) NOT NULL,
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
vat_rate DECIMAL(5,2) DEFAULT 25.00, -- Danish VAT
vat_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
-- e-conomic integration
economic_draft_id INTEGER,
economic_order_number VARCHAR(50),
exported_at TIMESTAMP,
exported_by INTEGER, -- Reference til users.id (read-only)
-- Metadata
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
created_by INTEGER, -- Reference til users.id (read-only)
CONSTRAINT check_total_hours CHECK (total_hours >= 0),
CONSTRAINT check_amounts CHECK (subtotal >= 0 AND vat_amount >= 0 AND total_amount >= 0),
CONSTRAINT check_status CHECK (status IN ('draft', 'exported', 'sent', 'cancelled'))
);
CREATE INDEX idx_tmodule_orders_customer ON tmodule_orders(customer_id);
CREATE INDEX idx_tmodule_orders_status ON tmodule_orders(status);
CREATE INDEX idx_tmodule_orders_date ON tmodule_orders(order_date);
CREATE INDEX idx_tmodule_orders_economic ON tmodule_orders(economic_draft_id);
CREATE UNIQUE INDEX idx_tmodule_orders_number ON tmodule_orders(order_number) WHERE order_number IS NOT NULL;
-- ============================================================================
-- ORDRE-LINJER (detaljer pr. case eller gruppering)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_order_lines (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES tmodule_orders(id) ON DELETE CASCADE,
case_id INTEGER REFERENCES tmodule_cases(id) ON DELETE SET NULL,
-- Linje-detaljer
line_number INTEGER NOT NULL,
description TEXT NOT NULL,
quantity DECIMAL(8,2) NOT NULL, -- Timer
unit_price DECIMAL(10,2) NOT NULL,
line_total DECIMAL(12,2) NOT NULL,
-- Reference til tidsregistreringer
time_entry_ids INTEGER[], -- Array af tmodule_times.id
-- e-conomic mapping
product_number VARCHAR(50),
account_number VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_line_number CHECK (line_number > 0),
CONSTRAINT check_quantity CHECK (quantity > 0),
CONSTRAINT check_amounts_line CHECK (unit_price >= 0 AND line_total >= 0)
);
CREATE INDEX idx_tmodule_order_lines_order ON tmodule_order_lines(order_id);
CREATE INDEX idx_tmodule_order_lines_case ON tmodule_order_lines(case_id);
-- ============================================================================
-- AUDIT LOG (fuld sporbarhed af alle handlinger)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_sync_log (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL, -- sync_started|sync_completed|approval|rejection|export|uninstall
entity_type VARCHAR(50), -- time_entry|order|customer|case
entity_id INTEGER,
user_id INTEGER, -- Reference til users.id (read-only)
-- Event-specifik data
details JSONB,
-- Metadata
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_event_type CHECK (event_type IN (
'sync_started', 'sync_completed', 'sync_failed',
'approval', 'rejection', 'bulk_approval',
'order_created', 'order_updated', 'order_cancelled',
'export_started', 'export_completed', 'export_failed',
'module_installed', 'module_uninstalled'
))
);
CREATE INDEX idx_tmodule_sync_log_event ON tmodule_sync_log(event_type);
CREATE INDEX idx_tmodule_sync_log_entity ON tmodule_sync_log(entity_type, entity_id);
CREATE INDEX idx_tmodule_sync_log_user ON tmodule_sync_log(user_id);
CREATE INDEX idx_tmodule_sync_log_created ON tmodule_sync_log(created_at DESC);
-- ============================================================================
-- TRIGGERS FOR AUTO-UPDATE TIMESTAMPS
-- ============================================================================
CREATE OR REPLACE FUNCTION tmodule_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tmodule_customers_update
BEFORE UPDATE ON tmodule_customers
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_cases_update
BEFORE UPDATE ON tmodule_cases
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_times_update
BEFORE UPDATE ON tmodule_times
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_orders_update
BEFORE UPDATE ON tmodule_orders
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
-- ============================================================================
-- AUTO-GENERATE ORDER NUMBERS
-- ============================================================================
CREATE OR REPLACE FUNCTION tmodule_generate_order_number()
RETURNS TRIGGER AS $$
DECLARE
date_prefix VARCHAR(8);
seq_num INTEGER;
new_number VARCHAR(50);
BEGIN
IF NEW.order_number IS NULL THEN
-- Format: TT-YYYYMMDD-XXX
date_prefix := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
-- Find næste sekvensnummer for dagen
SELECT COALESCE(MAX(
CAST(SUBSTRING(order_number FROM 'TT-\d{8}-(\d+)') AS INTEGER)
), 0) + 1
INTO seq_num
FROM tmodule_orders
WHERE order_number LIKE 'TT-' || date_prefix || '-%';
new_number := 'TT-' || date_prefix || '-' || LPAD(seq_num::TEXT, 3, '0');
NEW.order_number := new_number;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tmodule_orders_generate_number
BEFORE INSERT ON tmodule_orders
FOR EACH ROW EXECUTE FUNCTION tmodule_generate_order_number();
-- ============================================================================
-- VIEWS FOR COMMON QUERIES
-- ============================================================================
-- Oversigt over godkendelsesstatus pr. kunde
CREATE OR REPLACE VIEW tmodule_approval_stats AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
c.uses_time_card AS uses_time_card,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_entries,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'pending' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS pending_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS approved_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS rejected_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS billed_count,
SUM(t.original_hours) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_original_hours,
SUM(t.approved_hours) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS total_approved_hours,
MAX(t.worked_date) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS latest_work_date,
MAX(t.last_synced_at) FILTER (WHERE t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0') AS last_sync
FROM tmodule_customers c
LEFT JOIN tmodule_times t ON c.id = t.customer_id
GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card;
-- Næste tid der skal godkendes (wizard helper)
CREATE OR REPLACE VIEW tmodule_next_pending AS
SELECT
t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.status = 'pending'
AND t.billable = true -- Only billable timelogs
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' -- Only not-invoiced timelogs
ORDER BY cust.name, c.title, t.worked_date;
-- Order summary med linjer
CREATE OR REPLACE VIEW tmodule_order_details AS
SELECT
o.id AS order_id,
o.order_number,
o.order_date,
o.status AS order_status,
o.total_hours,
o.total_amount,
o.economic_draft_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
COUNT(DISTINCT l.id) AS line_count,
COUNT(DISTINCT t.id) AS time_entry_count
FROM tmodule_orders o
JOIN tmodule_customers c ON o.customer_id = c.id
LEFT JOIN tmodule_order_lines l ON o.id = l.order_id
LEFT JOIN tmodule_times t ON t.id = ANY(l.time_entry_ids)
GROUP BY o.id, o.order_number, o.order_date, o.status, o.total_hours,
o.total_amount, o.economic_draft_id, c.name, c.vtiger_id;
-- ============================================================================
-- COMMENTS FOR DOCUMENTATION
-- ============================================================================
COMMENT ON TABLE tmodule_metadata IS 'Metadata og konfiguration for tidsregistreringsmodulet';
COMMENT ON TABLE tmodule_customers IS 'Read-only cache af vTiger kunder (isoleret kopi)';
COMMENT ON TABLE tmodule_cases IS 'Read-only cache af vTiger cases/projekter (isoleret kopi)';
COMMENT ON TABLE tmodule_times IS 'Tidsregistreringer importeret fra vTiger med godkendelsesstatus';
COMMENT ON TABLE tmodule_orders IS 'Genererede ordrer fra godkendte tider';
COMMENT ON TABLE tmodule_order_lines IS 'Ordre-linjer med reference til tidsregistreringer';
COMMENT ON TABLE tmodule_sync_log IS 'Fuld audit log af alle modulhandlinger';
COMMENT ON COLUMN tmodule_times.status IS 'pending=Afventer godkendelse, approved=Godkendt, rejected=Afvist, billed=Faktureret';
COMMENT ON COLUMN tmodule_times.approved_hours IS 'Timer efter brugerens godkendelse og evt. afrunding';
COMMENT ON COLUMN tmodule_times.rounded_to IS 'Afrundingsinterval brugt (0.5, 1.0, etc.)';
COMMENT ON COLUMN tmodule_orders.status IS 'draft=Kladde, exported=Sendt til e-conomic, sent=Sendt til kunde, cancelled=Annulleret';
-- ============================================================================
-- INITIAL DATA LOG
-- ============================================================================
INSERT INTO tmodule_sync_log (event_type, details)
VALUES (
'module_installed',
jsonb_build_object(
'version', '1.0.0',
'migration', '013_timetracking_module.sql',
'timestamp', CURRENT_TIMESTAMP
)
);
-- ============================================================================
-- UNINSTALL SCRIPT (bruges ved modul-sletning)
-- ============================================================================
-- ADVARSEL: Dette script sletter ALLE data i modulet!
-- Kør kun hvis modulet skal fjernes fuldstændigt.
--
-- For at uninstalle, kør følgende kommandoer i rækkefølge:
--
-- DROP VIEW IF EXISTS tmodule_order_details CASCADE;
-- DROP VIEW IF EXISTS tmodule_next_pending CASCADE;
-- DROP VIEW IF EXISTS tmodule_approval_stats CASCADE;
--
-- DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders;
-- DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders;
-- DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times;
-- DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases;
-- DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers;
--
-- DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE;
-- DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE;
--
-- DROP TABLE IF EXISTS tmodule_sync_log CASCADE;
-- DROP TABLE IF EXISTS tmodule_order_lines CASCADE;
-- DROP TABLE IF EXISTS tmodule_orders CASCADE;
-- DROP TABLE IF EXISTS tmodule_times CASCADE;
-- DROP TABLE IF EXISTS tmodule_cases CASCADE;
-- DROP TABLE IF EXISTS tmodule_customers CASCADE;
-- DROP TABLE IF EXISTS tmodule_metadata CASCADE;
--
-- -- Log uninstall i system log hvis muligt
-- -- (Dette vil fejle hvis tmodule_sync_log er droppet, men det er OK)
-- DO $$
-- BEGIN
-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tmodule_sync_log') THEN
-- INSERT INTO tmodule_sync_log (event_type, details)
-- VALUES ('module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP));
-- END IF;
-- EXCEPTION WHEN OTHERS THEN
-- -- Ignorer fejl - tabellen er måske allerede slettet
-- NULL;
-- END $$;
--
-- ============================================================================