omnix-sopiga/docs/Gadai_collection_broadcast_simplified_schema.sql
2026-08-07 12:36:57 +07:00

319 lines
13 KiB
PL/PgSQL

-- ============================================================================
-- GADAI MULIA - COLLECTION BROADCAST TO SOPIGA COLLAR
-- SIMPLIFIED SCHEMA: Denormalized broadcast_staging
-- ============================================================================
CREATE SCHEMA IF NOT EXISTS collection_broadcasts;
-- ============================================================================
-- 1. CONFIGURATION TABLES (Reference only)
-- ============================================================================
-- Sopiga Template Config (pre-setup di Sopiga)
CREATE TABLE collection_broadcasts.sopiga_template_config (
id BIGSERIAL PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
sopiga_template_id INT NOT NULL UNIQUE,
template_type VARCHAR(50), -- utility, marketing, notification
channel VARCHAR(50), -- waba
description TEXT,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_sopiga_template_active ON collection_broadcasts.sopiga_template_config(active);
-- Template Variable Mapping (untuk dynamic message building)
CREATE TABLE collection_broadcasts.template_variable_mapping (
id BIGSERIAL PRIMARY KEY,
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id) ON DELETE CASCADE,
variable_order INT NOT NULL, -- 1, 2, 3, ... (urutan interpolasi)
sopiga_variable_name VARCHAR(100) NOT NULL, -- "Nama", "TotalTagihan", "NoKontrak", etc
variable_type VARCHAR(50), -- string, integer, date, decimal
db_field_source VARCHAR(100) NOT NULL, -- Key di message_payload
is_required BOOLEAN DEFAULT TRUE,
example_value VARCHAR(500),
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT unique_template_variable UNIQUE (sopiga_template_id, sopiga_variable_name),
CONSTRAINT unique_variable_order UNIQUE (sopiga_template_id, variable_order)
);
CREATE INDEX idx_template_variable_mapping_template_id ON collection_broadcasts.template_variable_mapping(sopiga_template_id);
CREATE INDEX idx_template_variable_mapping_order ON collection_broadcasts.template_variable_mapping(sopiga_template_id, variable_order);
-- Sopiga Collar Config (pre-created di Sopiga)
CREATE TABLE collection_broadcasts.sopiga_collar_config (
id BIGSERIAL PRIMARY KEY,
broadcast_name VARCHAR(100) NOT NULL,
sopiga_collar_id INT NOT NULL UNIQUE,
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
status VARCHAR(50), -- open, closed
description TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_sopiga_collar_status ON collection_broadcasts.sopiga_collar_config(status);
-- ============================================================================
-- 2. BROADCAST STAGING (CORE) - DENORMALIZED
-- ============================================================================
-- Single source of truth: message_payload (JSONB)
-- Semua data sudah ada di sini, tidak perlu FK ke nasabah/contract
-- Worker query hanya dari tabel ini
CREATE TABLE collection_broadcasts.broadcast_staging (
id BIGSERIAL PRIMARY KEY,
-- Sopiga config
sopiga_collar_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_collar_config(sopiga_collar_id),
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
-- Message payload - SINGLE SOURCE OF TRUTH
-- Contains all fields needed: nasabah_nama, nasabah_phone, nominal_tagihan, tanggal_tempo, invoice_pdf_url, contract_no, etc
-- Struktur dinamis sesuai template variables
-- Example: {
-- "nasabah_nama": "Budi Santoso",
-- "nasabah_phone": "6281234567890",
-- "nominal_tagihan": 1500000,
-- "tanggal_tempo": "2026-06-30",
-- "invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
-- "contract_no": "GAD-2026-001"
-- }
message_payload JSONB NOT NULL,
-- Status tracking
status VARCHAR(50) DEFAULT 'pending' NOT NULL,
-- pending: waiting to dispatch
-- dispatched: sent to Sopiga queue (HTTP 202 Accepted)
-- delivered: confirmed delivered to WhatsApp
-- failed: error occurred
-- retry_scheduled: queued for retry
error_message TEXT,
error_count INT DEFAULT 0,
-- Sopiga response
sopiga_recipient_detail_id BIGINT,
sopiga_external_id VARCHAR(100),
-- Timestamps
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
dispatched_at TIMESTAMP,
delivered_at TIMESTAMP,
failed_at TIMESTAMP,
CONSTRAINT status_valid CHECK (status IN ('pending', 'dispatched', 'delivered', 'failed', 'retry_scheduled')),
CONSTRAINT error_count_positive CHECK (error_count >= 0)
);
-- Indexes untuk worker & monitoring queries
CREATE INDEX idx_broadcast_staging_status ON collection_broadcasts.broadcast_staging(status);
CREATE INDEX idx_broadcast_staging_created_at ON collection_broadcasts.broadcast_staging(created_at DESC);
CREATE INDEX idx_broadcast_staging_collar_id ON collection_broadcasts.broadcast_staging(sopiga_collar_id);
CREATE INDEX idx_broadcast_staging_template_id ON collection_broadcasts.broadcast_staging(sopiga_template_id);
-- Composite index untuk worker (most frequent query)
CREATE INDEX idx_broadcast_staging_pending_query
ON collection_broadcasts.broadcast_staging(status, created_at ASC)
WHERE status = 'pending';
-- Composite index untuk retry queue
CREATE INDEX idx_broadcast_staging_retry_query
ON collection_broadcasts.broadcast_staging(status, error_count, created_at ASC)
WHERE status = 'retry_scheduled' AND error_count < 3;
-- ============================================================================
-- 3. AUDIT & LOGGING
-- ============================================================================
-- Status transition audit log
CREATE TABLE collection_broadcasts.broadcast_audit_log (
id BIGSERIAL PRIMARY KEY,
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
old_status VARCHAR(50),
new_status VARCHAR(50) NOT NULL,
reason VARCHAR(500),
sopiga_response JSONB,
changed_by VARCHAR(100) DEFAULT 'system',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_broadcast_audit_log_broadcast_id ON collection_broadcasts.broadcast_audit_log(broadcast_id);
CREATE INDEX idx_broadcast_audit_log_created_at ON collection_broadcasts.broadcast_audit_log(created_at DESC);
-- Error tracking
CREATE TABLE collection_broadcasts.broadcast_error_log (
id BIGSERIAL PRIMARY KEY,
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
error_type VARCHAR(100), -- network_error, api_error, validation_error
error_code VARCHAR(50),
error_message TEXT,
error_details JSONB,
attempt_number INT,
next_retry_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_broadcast_error_log_broadcast_id ON collection_broadcasts.broadcast_error_log(broadcast_id);
CREATE INDEX idx_broadcast_error_log_error_type ON collection_broadcasts.broadcast_error_log(error_type);
-- Sopiga delivery status sync
CREATE TABLE collection_broadcasts.sopiga_sync_job (
id BIGSERIAL PRIMARY KEY,
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
sopiga_recipient_detail_id BIGINT,
last_synced_at TIMESTAMP,
last_status_from_sopiga VARCHAR(50),
sync_count INT DEFAULT 0,
next_sync_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_sopiga_sync_job_broadcast_id ON collection_broadcasts.sopiga_sync_job(broadcast_id);
CREATE INDEX idx_sopiga_sync_job_next_sync_at ON collection_broadcasts.sopiga_sync_job(next_sync_at);
-- ============================================================================
-- 4. VIEWS
-- ============================================================================
-- Template variables dengan urutan (untuk Worker reference)
CREATE VIEW collection_broadcasts.v_template_variables_ordered AS
SELECT
tvm.sopiga_template_id,
stc.template_name,
tvm.variable_order,
tvm.sopiga_variable_name,
tvm.variable_type,
tvm.db_field_source,
tvm.is_required,
tvm.example_value
FROM collection_broadcasts.template_variable_mapping tvm
JOIN collection_broadcasts.sopiga_template_config stc ON tvm.sopiga_template_id = stc.sopiga_template_id
WHERE stc.active = TRUE
ORDER BY tvm.sopiga_template_id, tvm.variable_order;
-- Collar summary
CREATE VIEW collection_broadcasts.v_collar_summary AS
SELECT
sc.sopiga_collar_id,
sc.broadcast_name,
COUNT(*) as total_records,
COUNT(CASE WHEN bs.status = 'pending' THEN 1 END) as pending,
COUNT(CASE WHEN bs.status = 'dispatched' THEN 1 END) as dispatched,
COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) as delivered,
COUNT(CASE WHEN bs.status = 'failed' THEN 1 END) as failed,
ROUND(100.0 * COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
FROM collection_broadcasts.sopiga_collar_config sc
LEFT JOIN collection_broadcasts.broadcast_staging bs ON sc.sopiga_collar_id = bs.sopiga_collar_id
GROUP BY sc.sopiga_collar_id, sc.broadcast_name;
-- Failed records (last 24h)
CREATE VIEW collection_broadcasts.v_failed_records_24h AS
SELECT
id,
sopiga_collar_id,
sopiga_template_id,
message_payload->>'nasabah_nama' as nasabah_nama,
message_payload->>'nasabah_phone' as nasabah_phone,
error_message,
error_count,
failed_at,
created_at
FROM collection_broadcasts.broadcast_staging
WHERE status = 'failed' AND created_at > NOW() - INTERVAL 1 DAY
ORDER BY failed_at DESC;
-- Delivery rate (last 24h)
CREATE VIEW collection_broadcasts.v_delivery_rate_24h AS
SELECT
COUNT(*) as total,
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as delivered,
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
COUNT(CASE WHEN status = 'dispatched' THEN 1 END) as in_progress,
ROUND(100.0 * COUNT(CASE WHEN status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
FROM collection_broadcasts.broadcast_staging
WHERE created_at > NOW() - INTERVAL 1 DAY;
-- ============================================================================
-- 5. HELPER FUNCTIONS
-- ============================================================================
-- Get template variables by template_id
CREATE OR REPLACE FUNCTION collection_broadcasts.get_template_variables(
p_sopiga_template_id INT
)
RETURNS TABLE(
variable_order INT,
sopiga_variable_name VARCHAR,
variable_type VARCHAR,
db_field_source VARCHAR,
is_required BOOLEAN
) AS $$
BEGIN
RETURN QUERY
SELECT
tvm.variable_order,
tvm.sopiga_variable_name,
tvm.variable_type,
tvm.db_field_source,
tvm.is_required
FROM collection_broadcasts.template_variable_mapping tvm
WHERE tvm.sopiga_template_id = p_sopiga_template_id
ORDER BY tvm.variable_order ASC;
END;
$$ LANGUAGE plpgsql;
-- Update status dengan audit log
CREATE OR REPLACE FUNCTION collection_broadcasts.update_broadcast_status(
p_broadcast_id BIGINT,
p_new_status VARCHAR,
p_error_message TEXT DEFAULT NULL,
p_sopiga_response JSONB DEFAULT NULL
)
RETURNS VOID AS $$
DECLARE
v_old_status VARCHAR;
BEGIN
SELECT status INTO v_old_status
FROM collection_broadcasts.broadcast_staging
WHERE id = p_broadcast_id;
UPDATE collection_broadcasts.broadcast_staging
SET
status = p_new_status,
error_message = p_error_message,
updated_at = NOW(),
dispatched_at = CASE WHEN p_new_status = 'dispatched' THEN NOW() ELSE dispatched_at END,
delivered_at = CASE WHEN p_new_status = 'delivered' THEN NOW() ELSE delivered_at END,
failed_at = CASE WHEN p_new_status = 'failed' THEN NOW() ELSE failed_at END
WHERE id = p_broadcast_id;
INSERT INTO collection_broadcasts.broadcast_audit_log (broadcast_id, old_status, new_status, sopiga_response)
VALUES (p_broadcast_id, v_old_status, p_new_status, p_sopiga_response);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- 6. INITIALIZATION DATA
-- ============================================================================
-- Insert sample template
INSERT INTO collection_broadcasts.sopiga_template_config (template_name, sopiga_template_id, channel, template_type, description)
VALUES ('Collection Invoice May 2026', 2, 'waba', 'utility', 'Invoice bulanan untuk collection Mei 2026');
-- Insert template variables
INSERT INTO collection_broadcasts.template_variable_mapping (sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required, example_value)
VALUES
(2, 1, 'Nama', 'string', 'nasabah_nama', TRUE, 'Budi Santoso'),
(2, 2, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE, '1500000'),
(2, 3, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE, '2026-06-30');
-- Insert collar config
INSERT INTO collection_broadcasts.sopiga_collar_config (broadcast_name, sopiga_collar_id, sopiga_template_id, status, description)
VALUES ('Collection Invoices May 2026', 70, 2, 'open', 'Broadcast collar untuk collection invoice bulanan Mei 2026');