346 lines
18 KiB
PL/PgSQL
346 lines
18 KiB
PL/PgSQL
-- =============================================================================
|
|
-- PostgreSQL Schema — CPSP Dashboard ERD
|
|
-- Source: ERD_dashboard.png (updated 2026-09-09 for GM cycle-close flow)
|
|
--
|
|
-- user_access.status: active | inactive | superadmin
|
|
-- user_access.display_name: shown in dashboard UI (login still uses user_name)
|
|
-- (Django syncs is_active/is_staff/is_superuser from status on save:
|
|
-- inactive => all false; superadmin => is_active/is_superuser true,
|
|
-- is_staff false; active => is_active true, is_superuser false, is_staff
|
|
-- left untouched so operators can be marked staff)
|
|
-- user_access → sites → kandang → cycles
|
|
-- user_access → api_keys (X-API-Key auth; hashed at rest)
|
|
-- cycles.status: active | pending_close | closed (GM approves end_date)
|
|
-- cycles.close_requested_by_id → user_access (optional)
|
|
-- kandang → flock → iot_panel
|
|
-- 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 app's role
|
|
-- (active | inactive | superadmin); Django auth flags are synced from it on
|
|
-- save (is_staff stays untouched for active users, superadmin => is_staff
|
|
-- false, is_superuser true).
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS user_access (
|
|
user_id SERIAL PRIMARY KEY,
|
|
user_name VARCHAR(30) NOT NULL UNIQUE,
|
|
display_name VARCHAR(100) NOT NULL,
|
|
password VARCHAR(128) NOT NULL,
|
|
last_login TIMESTAMP WITH TIME ZONE,
|
|
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_staff BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
status VARCHAR(30) NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status);
|
|
|
|
-- Django PermissionsMixin many-to-many tables.
|
|
CREATE TABLE IF NOT EXISTS user_access_groups (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
|
|
group_id INTEGER NOT NULL REFERENCES auth_group (id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_access_user_permissions (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
|
|
permission_id INTEGER NOT NULL REFERENCES auth_permission (id) ON DELETE CASCADE
|
|
);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 1b. API Keys (machine auth via X-API-Key; FK → user_access)
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 2. Sites
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS sites (
|
|
site_id SERIAL PRIMARY KEY,
|
|
site_name VARCHAR(30) NOT NULL,
|
|
user_id INTEGER NOT NULL REFERENCES user_access (user_id) ON DELETE CASCADE,
|
|
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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 3. Kandang (Coop / Pen)
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS kandang (
|
|
kandang_id SERIAL PRIMARY KEY,
|
|
kandang_name VARCHAR(30) NOT NULL,
|
|
site_id INTEGER NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
|
|
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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 4. Cycles
|
|
-- end_date is nullable until GM approves close (pending_close → closed).
|
|
-- Soft bound while open uses dashboard visible-through (app-side).
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS cycles (
|
|
cycle_id SERIAL PRIMARY KEY,
|
|
total_days INTEGER NOT NULL DEFAULT 1,
|
|
start_date DATE NOT NULL,
|
|
end_date DATE, -- set on GM approve; NULL while active / pending_close
|
|
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,
|
|
status VARCHAR(30) NOT NULL DEFAULT 'active', -- active | pending_close | closed
|
|
proposed_end_date DATE, -- staff proposed close date (cleared after decision)
|
|
close_requested_at TIMESTAMP WITH TIME ZONE,
|
|
close_requested_by_id INTEGER REFERENCES user_access (user_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,
|
|
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
|
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_close_requested_by_id ON cycles (close_requested_by_id);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 5. Flock
|
|
-- Belongs to kandang (persists across cycles); used by IoT panel.
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS flock (
|
|
flock_id SERIAL PRIMARY KEY,
|
|
flock_name VARCHAR(30) NOT NULL,
|
|
kandang_id INTEGER NOT NULL REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
|
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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 6. 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 SERIAL PRIMARY KEY,
|
|
date DATE NOT NULL,
|
|
timestamp TIMESTAMP WITH TIME ZONE,
|
|
wind_speed FLOAT NOT NULL,
|
|
humidity FLOAT NOT NULL,
|
|
water_total FLOAT NOT NULL,
|
|
average_temperature FLOAT NOT NULL,
|
|
experience_temperature FLOAT NOT NULL,
|
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
flock_id INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 7. AI Insight
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS ai_insight (
|
|
id SERIAL 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 INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 8. Feed Sacks
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS feed_sacks (
|
|
fs_id SERIAL 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 INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 9. Chicken Counting
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS chicken_counting (
|
|
cc_id SERIAL 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 INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 10. Chicken Weight
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS chicken_weight (
|
|
cw_id SERIAL PRIMARY KEY,
|
|
date DATE NOT NULL,
|
|
age INTEGER NOT NULL,
|
|
doc_weight INTEGER NOT NULL,
|
|
average_weight FLOAT NOT NULL,
|
|
chicken_count INTEGER NOT NULL,
|
|
uniformity FLOAT NOT NULL,
|
|
average_daily_gain FLOAT 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 INTEGER 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_chicken_weight_cycle_date ON chicken_weight (cycle_id, date);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 11. Manual Input
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS manual_input (
|
|
manual_id SERIAL 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 FLOAT NOT NULL DEFAULT 0,
|
|
harvest_weight_manual_total FLOAT NOT NULL DEFAULT 0,
|
|
manual_weight FLOAT NOT NULL DEFAULT 0,
|
|
average_harvest_day_manual FLOAT NOT NULL DEFAULT 0,
|
|
average_harvest_weight_manual FLOAT NOT NULL DEFAULT 0,
|
|
fcr_manual FLOAT NOT NULL DEFAULT 0,
|
|
eef_manual FLOAT 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 INTEGER 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);
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 12. KPI (aggregates data from cycle + source records)
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS kpi (
|
|
kpi_id SERIAL 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 FLOAT NOT NULL DEFAULT 0,
|
|
harvest_weight_total FLOAT NOT NULL DEFAULT 0,
|
|
chicken_life INTEGER NOT NULL DEFAULT 0,
|
|
chicken_life_percentage FLOAT NOT NULL DEFAULT 0,
|
|
iot_weight FLOAT NOT NULL DEFAULT 0,
|
|
average_harvest_day FLOAT NOT NULL DEFAULT 0,
|
|
average_harvest_weight FLOAT NOT NULL DEFAULT 0,
|
|
fcr FLOAT NOT NULL DEFAULT 0,
|
|
eef FLOAT 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 INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
|
|
cc_id INTEGER NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT,
|
|
cw_id INTEGER NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT,
|
|
fs_id INTEGER NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT,
|
|
manual_id INTEGER 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', 'sites', 'kandang', 'cycles', 'flock',
|
|
'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;
|
|
$$;
|