-- ============================================================================= -- PostgreSQL Schema — CPSP Executive Dashboard ERD -- Source: live DB (dashboard-cpsp-executive) + Django models (updated 2026-09-14) -- -- user_access.status: -- inactive | gm | gm_admin | buh | bu_admin | director | director_admin | -- account_admin | superuser -- (Django syncs is_active/is_superuser from status on save: -- inactive => both false; superuser => both true; other executive roles => -- is_active true, is_superuser false. Product roles never use is_staff.) -- -- user_access → sites → kandang → cycles -- (sites.site_id = farm slug = active_sites.source_site_id when linked) -- kandang → flock → iot_panel -- user_access → api_keys -- user_access → active_sites (managed_by) -- sites ↔ active_sites (optional 1:1) -- cycles → active_sites (mirror source) + close-approval user FKs -- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight, -- manual_input, kpi -- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input -- ============================================================================= -- ----------------------------------------------------------------------------- -- 1. User Access -- Django AbstractBaseUser + PermissionsMixin. `status` is the executive -- role; is_active/is_superuser are derived from it on save. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS user_access ( user_id BIGSERIAL PRIMARY KEY, user_name VARCHAR(30) NOT NULL UNIQUE, display_name VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, last_login TIMESTAMP WITH TIME ZONE, is_superuser BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, status VARCHAR(30) NOT NULL DEFAULT 'gm', managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL, managed_by_pending_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL, managed_by_requested_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status); CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_id ON user_access (managed_by_id); CREATE INDEX IF NOT EXISTS idx_user_access_managed_by_pending_id ON user_access (managed_by_pending_id); -- Django PermissionsMixin many-to-many tables. CREATE TABLE IF NOT EXISTS user_access_groups ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE, UNIQUE (user_id, group_id) ); CREATE TABLE IF NOT EXISTS user_access_user_permissions ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE, UNIQUE (user_id, permission_id) ); -- ----------------------------------------------------------------------------- -- 2. API Keys (inbound X-API-Key auth to HQ) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS api_keys ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, prefix VARCHAR(12) NOT NULL, key_hash VARCHAR(64) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, last_used_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys (user_id); CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys (prefix); -- ----------------------------------------------------------------------------- -- 3. Active Sites (farm-location dashboard-cpsp registry) -- source_site_id = farm-location sites.site_id slug (e.g. sukawarna-4821), -- sent by site dashboard on register-from-site (API field name: site_id). -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS active_sites ( active_site_id BIGSERIAL PRIMARY KEY, code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(120) NOT NULL, source_site_id VARCHAR(64) NOT NULL DEFAULT '', api_base_url VARCHAR(255) NOT NULL DEFAULT '', api_key VARCHAR(255) NOT NULL DEFAULT '', managed_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, last_synced_at TIMESTAMP WITH TIME ZONE, last_sync_status VARCHAR(32) NOT NULL DEFAULT '', last_sync_message TEXT NOT NULL DEFAULT '', created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_active_sites_managed_by_id ON active_sites (managed_by_id); CREATE INDEX IF NOT EXISTS idx_active_sites_source_site_id ON active_sites (source_site_id); -- ----------------------------------------------------------------------------- -- 4. Sites (HQ farm tree) -- site_id is the farm dashboard Site.site_id slug (e.g. sukawarna-4821). -- When linked, it equals active_sites.source_site_id. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS sites ( site_id VARCHAR(64) PRIMARY KEY, site_name VARCHAR(30) NOT NULL, user_id BIGINT NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE, active_site_id BIGINT UNIQUE REFERENCES active_sites (active_site_id) ON DELETE SET NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites (user_id); -- ----------------------------------------------------------------------------- -- 5. Kandang (Coop / Pen) -- Optional source_kandang_id = farm kandang PK for mirror upserts. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS kandang ( kandang_id BIGSERIAL PRIMARY KEY, kandang_name VARCHAR(30) NOT NULL, feed_in_button_urls JSONB NOT NULL DEFAULT '[]'::jsonb, site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE, source_kandang_id BIGINT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id); CREATE UNIQUE INDEX IF NOT EXISTS uniq_kandang_site_source ON kandang (site_id, source_kandang_id) WHERE source_kandang_id IS NOT NULL; -- ----------------------------------------------------------------------------- -- 6. Flock -- Belongs to kandang (not cycle). IoT readings attach here. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS flock ( flock_id BIGSERIAL PRIMARY KEY, flock_name VARCHAR(30) NOT NULL, kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE, source_flock_id BIGINT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_flock_kandang_id ON flock (kandang_id); CREATE UNIQUE INDEX IF NOT EXISTS uniq_flock_kandang_source ON flock (kandang_id, source_flock_id) WHERE source_flock_id IS NOT NULL; -- ----------------------------------------------------------------------------- -- 7. Cycles -- end_date NULL while open; status active | pending_close | closed. -- Optional active_site + source_cycle_id identify the mirrored farm cycle. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS cycles ( cycle_id BIGSERIAL PRIMARY KEY, total_days INTEGER NOT NULL DEFAULT 1, start_date DATE NOT NULL, end_date DATE, doc_in_weight INTEGER NOT NULL, doc_in_count INTEGER NOT NULL, feed_initial_balance INTEGER NOT NULL DEFAULT 0, feed_initial_balance_date DATE, feed_initial_balance_iot INTEGER, feed_initial_balance_accuracy DOUBLE PRECISION, feed_initial_balance_sync_error TEXT, status VARCHAR(30) NOT NULL DEFAULT 'active', kandang_id BIGINT NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE, active_site_id BIGINT REFERENCES active_sites (active_site_id) ON DELETE SET NULL, source_cycle_id BIGINT, proposed_end_date DATE, close_requested_at TIMESTAMP WITH TIME ZONE, close_requested_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL, close_approved_by_id BIGINT REFERENCES user_access (user_id) ON DELETE SET NULL, close_approved_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_cycles_date_range CHECK (end_date IS NULL OR end_date >= start_date) ); CREATE INDEX IF NOT EXISTS idx_cycles_kandang_id ON cycles (kandang_id); CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles (status); CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles (start_date); CREATE INDEX IF NOT EXISTS idx_cycles_active_site_id ON cycles (active_site_id); CREATE INDEX IF NOT EXISTS idx_cycles_close_requested_by_id ON cycles (close_requested_by_id); CREATE INDEX IF NOT EXISTS idx_cycles_close_approved_by_id ON cycles (close_approved_by_id); CREATE UNIQUE INDEX IF NOT EXISTS uniq_cycle_active_source ON cycles (active_site_id, source_cycle_id) WHERE active_site_id IS NOT NULL AND source_cycle_id IS NOT NULL; -- ----------------------------------------------------------------------------- -- 8. IoT Panel -- One row per sensor reading; the panel polls every 10 minutes, so a cycle -- day holds up to 144 rows. `timestamp` carries the reading time; `date` is -- kept denormalized for fast daily grouping. -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS iot_panel ( panel_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, timestamp TIMESTAMP WITH TIME ZONE, wind_speed DOUBLE PRECISION NOT NULL DEFAULT 0, humidity DOUBLE PRECISION NOT NULL DEFAULT 0, water_total DOUBLE PRECISION NOT NULL DEFAULT 0, average_temperature DOUBLE PRECISION NOT NULL DEFAULT 0, experience_temperature DOUBLE PRECISION NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, flock_id BIGINT NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_iot_panel_flock_id ON iot_panel (flock_id); CREATE INDEX IF NOT EXISTS idx_iot_panel_date ON iot_panel (date); CREATE INDEX IF NOT EXISTS idx_iot_panel_timestamp ON iot_panel (timestamp); CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (flock_id, timestamp); -- ----------------------------------------------------------------------------- -- 9. AI Insight -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS ai_insight ( id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, insight_text TEXT NOT NULL, alert VARCHAR(100) NOT NULL, section VARCHAR(100) NOT NULL, session VARCHAR(100) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id); CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session ON ai_insight (cycle_id, date, section, session); -- ----------------------------------------------------------------------------- -- 10. Feed Sacks -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS feed_sacks ( fs_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, in_today INTEGER NOT NULL DEFAULT 0, out_today INTEGER NOT NULL DEFAULT 0, in_total INTEGER NOT NULL DEFAULT 0, out_total INTEGER NOT NULL DEFAULT 0, feed_use_today INTEGER NOT NULL DEFAULT 0, feed_use_total INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_feed_sacks_cycle_id ON feed_sacks (cycle_id); CREATE INDEX IF NOT EXISTS idx_feed_sacks_date ON feed_sacks (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_feed_sacks_cycle_date ON feed_sacks (cycle_id, date); -- ----------------------------------------------------------------------------- -- 11. Chicken Counting -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS chicken_counting ( cc_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, total_count INTEGER NOT NULL DEFAULT 0, mortality_count INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chicken_counting_cycle_id ON chicken_counting (cycle_id); CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_counting_cycle_date ON chicken_counting (cycle_id, date); -- ----------------------------------------------------------------------------- -- 12. Chicken Weight -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS chicken_weight ( cw_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, age INTEGER NOT NULL, doc_weight INTEGER NOT NULL, average_weight DOUBLE PRECISION NOT NULL, chicken_count INTEGER NOT NULL, uniformity DOUBLE PRECISION NOT NULL, average_daily_gain DOUBLE PRECISION NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id); CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_cw_cycle_date ON chicken_weight (cycle_id, date); -- ----------------------------------------------------------------------------- -- 13. Manual Input -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS manual_input ( manual_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, age_manual INTEGER NOT NULL DEFAULT 0, mortality_manual INTEGER NOT NULL DEFAULT 0, mortality_manual_total INTEGER NOT NULL DEFAULT 0, feed_in_manual INTEGER NOT NULL DEFAULT 0, feed_use_manual INTEGER NOT NULL DEFAULT 0, feed_in_manual_total INTEGER NOT NULL DEFAULT 0, feed_use_manual_total INTEGER NOT NULL DEFAULT 0, feed_out_manual INTEGER NOT NULL DEFAULT 0, feed_out_manual_total INTEGER NOT NULL DEFAULT 0, harvest_manual INTEGER NOT NULL DEFAULT 0, harvest_manual_total INTEGER NOT NULL DEFAULT 0, harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0, harvest_weight_manual_total DOUBLE PRECISION NOT NULL DEFAULT 0, manual_weight DOUBLE PRECISION NOT NULL DEFAULT 0, average_harvest_day_manual DOUBLE PRECISION NOT NULL DEFAULT 0, average_harvest_weight_manual DOUBLE PRECISION NOT NULL DEFAULT 0, fcr_manual DOUBLE PRECISION NOT NULL DEFAULT 0, eef_manual DOUBLE PRECISION NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_manual_input_cycle_id ON manual_input (cycle_id); CREATE INDEX IF NOT EXISTS idx_manual_input_date ON manual_input (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_manual_input_cycle_date ON manual_input (cycle_id, date); -- ----------------------------------------------------------------------------- -- 14. KPI (aggregates data from cycle + source records) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS kpi ( kpi_id BIGSERIAL PRIMARY KEY, date DATE NOT NULL, age INTEGER NOT NULL DEFAULT 0, mortality INTEGER NOT NULL DEFAULT 0, mortality_total INTEGER NOT NULL DEFAULT 0, feed INTEGER NOT NULL DEFAULT 0, feed_total INTEGER NOT NULL DEFAULT 0, harvest INTEGER NOT NULL DEFAULT 0, harvest_total INTEGER NOT NULL DEFAULT 0, harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0, harvest_weight_total DOUBLE PRECISION NOT NULL DEFAULT 0, chicken_life INTEGER NOT NULL DEFAULT 0, chicken_life_percentage DOUBLE PRECISION NOT NULL DEFAULT 0, iot_weight DOUBLE PRECISION NOT NULL DEFAULT 0, average_harvest_day DOUBLE PRECISION NOT NULL DEFAULT 0, average_harvest_weight DOUBLE PRECISION NOT NULL DEFAULT 0, fcr DOUBLE PRECISION NOT NULL DEFAULT 0, eef DOUBLE PRECISION NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE, cc_id BIGINT NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT, cw_id BIGINT NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT, fs_id BIGINT NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT, manual_id BIGINT NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT ); CREATE INDEX IF NOT EXISTS idx_kpi_cycle_id ON kpi (cycle_id); CREATE INDEX IF NOT EXISTS idx_kpi_date ON kpi (date); CREATE UNIQUE INDEX IF NOT EXISTS uniq_kpi_cycle_date ON kpi (cycle_id, date); -- ============================================================================= -- Auto-update updated_at on row changes -- ============================================================================= CREATE OR REPLACE FUNCTION erd_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; DO $$ DECLARE tbl TEXT; BEGIN FOREACH tbl IN ARRAY ARRAY[ 'user_access', 'api_keys', 'active_sites', 'sites', 'kandang', 'flock', 'cycles', 'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting', 'chicken_weight', 'manual_input', 'kpi' ] LOOP EXECUTE format('DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I', tbl, tbl); EXECUTE format( 'CREATE TRIGGER trg_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION erd_set_updated_at()', tbl, tbl ); END LOOP; END; $$;