feat(db): add v2.6 schema migration — global materials, supplier categories, inventory ledger, PM arrival flow, triggers
This commit is contained in:
@@ -0,0 +1,637 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- PROCUREMENT + INVENTORY SCHEMA v2.6 — PostgreSQL Migration
|
||||||
|
-- Integrated ER: Material → Supplier → Category + PM arrival flow + inventory
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- EXTENSIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- ENUMS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TYPE quote_status AS ENUM (
|
||||||
|
'offered',
|
||||||
|
'accepted',
|
||||||
|
'arrived',
|
||||||
|
'rejected',
|
||||||
|
'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE termsheet_status AS ENUM (
|
||||||
|
'offered',
|
||||||
|
'accepted',
|
||||||
|
'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE material_status AS ENUM (
|
||||||
|
'active',
|
||||||
|
'discontinued',
|
||||||
|
'draft'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE attribute_data_type AS ENUM (
|
||||||
|
'number',
|
||||||
|
'integer',
|
||||||
|
'string',
|
||||||
|
'enum',
|
||||||
|
'boolean',
|
||||||
|
'date'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE discount_type AS ENUM (
|
||||||
|
'none',
|
||||||
|
'percent',
|
||||||
|
'fixed'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE item_decision_status AS ENUM (
|
||||||
|
'pending',
|
||||||
|
'chosen',
|
||||||
|
'rejected'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE quote_event_type AS ENUM (
|
||||||
|
'offered',
|
||||||
|
'accepted',
|
||||||
|
'rejected',
|
||||||
|
'arrived',
|
||||||
|
'note',
|
||||||
|
'edited',
|
||||||
|
'expired'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The exact 10 supported inventory transaction types (no generic OTHER).
|
||||||
|
CREATE TYPE inventory_transaction_reason AS ENUM (
|
||||||
|
'PURCHASE',
|
||||||
|
'WITHDRAWAL',
|
||||||
|
'RETURN',
|
||||||
|
'TRANSFER_IN',
|
||||||
|
'TRANSFER_OUT',
|
||||||
|
'DAMAGE',
|
||||||
|
'WASTE',
|
||||||
|
'LOSS',
|
||||||
|
'FOUND',
|
||||||
|
'STOCK_COUNT'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Casdoor-backed roles. NULL = provisioned but not yet assigned.
|
||||||
|
CREATE TYPE user_role AS ENUM (
|
||||||
|
'pm',
|
||||||
|
'procurement_manager',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- USERS & IDENTITY
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
casdoor_id TEXT NOT NULL UNIQUE,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
role user_role,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- CATALOG DOMAIN
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------- UNITS ----------
|
||||||
|
CREATE TABLE units (
|
||||||
|
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
code VARCHAR NOT NULL UNIQUE,
|
||||||
|
name_fa VARCHAR,
|
||||||
|
dimension VARCHAR, -- length, mass, pressure, area, volume, voltage, count, ratio, time, currency
|
||||||
|
si_factor DECIMAL NOT NULL DEFAULT 1.0,
|
||||||
|
is_base BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- BRANDS ----------
|
||||||
|
CREATE TABLE brands (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_brands_name_country UNIQUE (name, country)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- MATERIAL CATEGORIES (the 8 FIXED topic categories) ----------
|
||||||
|
-- Seeded by init_db / seed_stack with slugs:
|
||||||
|
-- mechanical-installations-pipes, electrical-equipment-supplies,
|
||||||
|
-- doors-windows-glass, metals-steel-sections, construction-materials,
|
||||||
|
-- construction-chemicals, finishing-interior-decoration,
|
||||||
|
-- construction-tools-equipment
|
||||||
|
-- The app NEVER auto-creates categories.
|
||||||
|
CREATE TABLE material_categories (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- ATTRIBUTE DEFINITIONS (Global Attribute Dictionary) ----------
|
||||||
|
CREATE TABLE attribute_definitions (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
attribute_key TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
data_type attribute_data_type NOT NULL,
|
||||||
|
unit_id INTEGER REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
enum_values JSONB, -- Allowed options for enum types
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- CATEGORY ATTRIBUTES (Junction) ----------
|
||||||
|
CREATE TABLE category_attributes (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||||
|
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE CASCADE,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_filterable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
conditional_rules JSONB, -- UI Wizard rules
|
||||||
|
sort_order SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
|
||||||
|
-- category_id is NULLABLE and DERIVED automatically from the first linked
|
||||||
|
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
|
||||||
|
-- unique (uq_material_name). topic is a legacy display column no longer
|
||||||
|
-- written by the app.
|
||||||
|
CREATE TABLE materials (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
topic TEXT, -- LEGACY display column
|
||||||
|
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
status material_status NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_material_name UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- BRAND MATERIALS (M:N Junction) ----------
|
||||||
|
CREATE TABLE brand_materials (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
catalog_code TEXT,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_brand_material UNIQUE (brand_id, material_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- MATERIAL UNITS (Packaging / Unit Overrides) ----------
|
||||||
|
CREATE TABLE material_units (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
brand_material_id UUID REFERENCES brand_materials(id) ON DELETE CASCADE, -- NULL = generic fallback
|
||||||
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
conversion_factor NUMERIC(18,6) NOT NULL, -- Multiplier → canonical_unit_id
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
CONSTRAINT uq_material_unit UNIQUE (material_id, brand_material_id, unit_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- MATERIAL ATTRIBUTE VALUES (Typed EAV) ----------
|
||||||
|
CREATE TABLE material_attribute_values (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT,
|
||||||
|
value_number NUMERIC(18,6),
|
||||||
|
value_string TEXT,
|
||||||
|
value_boolean BOOLEAN,
|
||||||
|
value_date DATE,
|
||||||
|
CONSTRAINT uq_material_attr_value UNIQUE (material_id, attribute_definition_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- QUOTATION & PROCUREMENT DOMAIN
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------- SUPPLIERS (1 supplier → exactly 1 category) ----------
|
||||||
|
-- The old suppliers.categories JSONB (array of category names, added at
|
||||||
|
-- runtime by init_db) is DROPPED in v2.6 — replaced by category_id.
|
||||||
|
CREATE TABLE suppliers (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE RESTRICT,
|
||||||
|
location TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
contact_person TEXT,
|
||||||
|
tax_id TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_suppliers_category_id ON suppliers(category_id);
|
||||||
|
|
||||||
|
-- ---------- SUPPLIER MATERIALS (NEW — the direct supplier↔material edge) ----------
|
||||||
|
-- Auto-created whenever a quote line item is saved; managed via
|
||||||
|
-- /suppliers/{id}/materials and /materials/{id}/suppliers.
|
||||||
|
CREATE TABLE supplier_materials (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE CASCADE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_quoted_price NUMERIC(18,4),
|
||||||
|
last_quoted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_supplier_material UNIQUE (supplier_id, material_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_supplier_materials_material ON supplier_materials(material_id);
|
||||||
|
|
||||||
|
-- ---------- PROJECTS (1 project → 1 PM) ----------
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_date DATE,
|
||||||
|
pm_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- QUOTES ----------
|
||||||
|
CREATE TABLE quotes (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE RESTRICT,
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
quote_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expiry_date DATE,
|
||||||
|
accepted_date TIMESTAMPTZ,
|
||||||
|
arrived_date TIMESTAMPTZ,
|
||||||
|
status quote_status NOT NULL DEFAULT 'offered',
|
||||||
|
is_official BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE = formal; FALSE = estimate/draft
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- QUOTE EVENTS (Audit / History Log) ----------
|
||||||
|
CREATE TABLE quote_events (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
event_type quote_event_type NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}',
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
actor TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- LINE ITEMS ----------
|
||||||
|
CREATE TABLE line_items (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||||
|
brand_material_id UUID REFERENCES brand_materials(id) ON DELETE SET NULL,
|
||||||
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
quantity NUMERIC(18,4) NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- LINE ITEM SPECS (Historical Snapshot) ----------
|
||||||
|
CREATE TABLE line_item_specs (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||||||
|
attribute_definition_id UUID NOT NULL REFERENCES attribute_definitions(id) ON DELETE RESTRICT,
|
||||||
|
value_number NUMERIC(18,6),
|
||||||
|
value_string TEXT,
|
||||||
|
value_boolean BOOLEAN,
|
||||||
|
value_date DATE,
|
||||||
|
unit_id_at_time INTEGER REFERENCES units(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uq_lineitem_spec UNIQUE (line_item_id, attribute_definition_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- TERMSHEETS ----------
|
||||||
|
CREATE TABLE termsheets (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
status termsheet_status NOT NULL DEFAULT 'offered',
|
||||||
|
discount_type discount_type NOT NULL DEFAULT 'none',
|
||||||
|
discount_value NUMERIC(18,4) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- TERMSHEET SCHEDULE STEPS ----------
|
||||||
|
CREATE TABLE termsheet_schedule_steps (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
termsheet_id UUID NOT NULL REFERENCES termsheets(id) ON DELETE CASCADE,
|
||||||
|
step_no SMALLINT NOT NULL,
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
amount NUMERIC(18,2) NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
CONSTRAINT uq_termsheet_step UNIQUE (termsheet_id, step_no)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- ITEM TERMSHEETS (Line Item Pricing in a Termsheet) ----------
|
||||||
|
CREATE TABLE item_termsheets (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||||||
|
termsheet_id UUID NOT NULL REFERENCES termsheets(id) ON DELETE CASCADE,
|
||||||
|
unit_price NUMERIC(18,4) NOT NULL,
|
||||||
|
min_order_qty NUMERIC(18,4),
|
||||||
|
CONSTRAINT uq_item_termsheet UNIQUE (line_item_id, termsheet_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Convenience VIEW: total_price = unit_price × line_items.quantity
|
||||||
|
CREATE VIEW item_termsheets_with_total AS
|
||||||
|
SELECT
|
||||||
|
it.id,
|
||||||
|
it.line_item_id,
|
||||||
|
it.termsheet_id,
|
||||||
|
it.unit_price,
|
||||||
|
it.min_order_qty,
|
||||||
|
(it.unit_price * li.quantity)::NUMERIC(18,2) AS total_price
|
||||||
|
FROM item_termsheets it
|
||||||
|
JOIN line_items li ON li.id = it.line_item_id;
|
||||||
|
|
||||||
|
-- ---------- ITEM DECISIONS (1:1 with line_items) ----------
|
||||||
|
-- project_id: the SINGLE purchased project, picked by the procurement
|
||||||
|
-- manager when the item is chosen (auto-filled when the offer had one
|
||||||
|
-- project; validated against line_item_projects). Warehouses of this
|
||||||
|
-- project are the only valid arrival destinations.
|
||||||
|
CREATE TABLE item_decisions (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
line_item_id UUID NOT NULL UNIQUE REFERENCES line_items(id) ON DELETE CASCADE,
|
||||||
|
chosen_item_termsheet_id UUID REFERENCES item_termsheets(id) ON DELETE SET NULL,
|
||||||
|
status item_decision_status NOT NULL DEFAULT 'pending',
|
||||||
|
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
adjusted_unit_price NUMERIC(18,4),
|
||||||
|
adjusted_notes TEXT,
|
||||||
|
decided_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- ITEM RECEIPTS (1 item : N receipts — partial deliveries) ----------
|
||||||
|
-- The UNIQUE on item_decision_id is REMOVED in v2.6. A receipt means the
|
||||||
|
-- PM confirmed this delivery. received_qty is in the line item's unit;
|
||||||
|
-- inventory posting converts to the inventory row's unit.
|
||||||
|
CREATE TABLE item_receipts (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
item_decision_id UUID NOT NULL REFERENCES item_decisions(id) ON DELETE CASCADE,
|
||||||
|
confirmed_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
received_qty NUMERIC(18,4) NOT NULL,
|
||||||
|
on_time BOOLEAN,
|
||||||
|
quality_note TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_item_receipts_decision ON item_receipts(item_decision_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INVENTORY DOMAIN (fork's shipped model, with fixes)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ---------- WAREHOUSES (1 warehouse → exactly 1 project) ----------
|
||||||
|
CREATE TABLE warehouses (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- ITEM RECEIPT ALLOCATIONS (NEW — PM's split into warehouses) ----------
|
||||||
|
-- App rule: SUM(quantity) = item_receipts.received_qty and every
|
||||||
|
-- warehouse.project_id = item_decisions.project_id.
|
||||||
|
CREATE TABLE item_receipt_allocations (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
receipt_id UUID NOT NULL REFERENCES item_receipts(id) ON DELETE CASCADE,
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||||
|
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||||||
|
CONSTRAINT uq_receipt_warehouse UNIQUE (receipt_id, warehouse_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- INVENTORY (current stock snapshot) ----------
|
||||||
|
-- FIX (vs fork): brand_id is nullable, so the unique index uses COALESCE —
|
||||||
|
-- a plain unique index would allow duplicate stock rows for unbranded
|
||||||
|
-- materials (Postgres treats NULLs as distinct).
|
||||||
|
-- specs_hash = SHA256 of sorted canonical specs JSON ('default' when empty),
|
||||||
|
-- computed server-side on every write.
|
||||||
|
CREATE TABLE inventory (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE CASCADE,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||||
|
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
|
quantity NUMERIC(18,6) NOT NULL DEFAULT 0 CHECK (quantity >= 0),
|
||||||
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
specs JSONB NOT NULL DEFAULT '{}',
|
||||||
|
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_inventory_stock ON inventory (
|
||||||
|
warehouse_id,
|
||||||
|
material_id,
|
||||||
|
COALESCE(brand_id, '00000000-0000-0000-0000-000000000000'),
|
||||||
|
specs_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_inventory_warehouse_material ON inventory (warehouse_id, material_id);
|
||||||
|
|
||||||
|
-- ---------- INVENTORY TRANSACTIONS (full chronological ledger) ----------
|
||||||
|
-- quantity is SIGNED: positive = increase, negative = decrease.
|
||||||
|
-- STOCK_COUNT: recorded_qty/physical_qty + quantity = difference.
|
||||||
|
-- A transfer = two linked rows (TRANSFER_OUT + TRANSFER_IN) sharing
|
||||||
|
-- transfer_pair_id.
|
||||||
|
-- receipt_allocation_id links an auto-posted PURCHASE back to its
|
||||||
|
-- allocation — UNIQUE prevents double-posting the same delivery.
|
||||||
|
CREATE TABLE inventory_transactions (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
inventory_id UUID NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||||
|
receipt_allocation_id UUID REFERENCES item_receipt_allocations(id) ON DELETE SET NULL UNIQUE,
|
||||||
|
transaction_type inventory_transaction_reason NOT NULL,
|
||||||
|
quantity NUMERIC(18,6) NOT NULL,
|
||||||
|
recorded_qty NUMERIC(18,6),
|
||||||
|
physical_qty NUMERIC(18,6),
|
||||||
|
transfer_pair_id UUID,
|
||||||
|
performed_by TEXT,
|
||||||
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_inventory_tx_inventory_date ON inventory_transactions (inventory_id, transaction_date);
|
||||||
|
CREATE INDEX idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDEXES (additional performance indexes beyond PKs / UNIQUEs)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE INDEX idx_quotes_supplier_status ON quotes(supplier_id, status);
|
||||||
|
CREATE INDEX idx_quotes_project ON quotes(project_id);
|
||||||
|
CREATE INDEX idx_quote_events_quote_time ON quote_events(quote_id, occurred_at);
|
||||||
|
CREATE INDEX idx_line_items_quote ON line_items(quote_id);
|
||||||
|
CREATE INDEX idx_line_items_material ON line_items(material_id);
|
||||||
|
CREATE INDEX idx_termsheets_quote ON termsheets(quote_id);
|
||||||
|
CREATE INDEX idx_item_termsheets_termsheet ON item_termsheets(termsheet_id);
|
||||||
|
CREATE INDEX idx_brand_materials_material ON brand_materials(material_id);
|
||||||
|
CREATE INDEX idx_material_attr_values_def ON material_attribute_values(attribute_definition_id);
|
||||||
|
CREATE INDEX idx_category_attrs_category ON category_attributes(category_id);
|
||||||
|
CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id);
|
||||||
|
CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE quote_attachments (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('invoice', 'proforma')),
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
object_key TEXT NOT NULL,
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_quote_attachments_quote_id ON quote_attachments(quote_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- RUNTIME TABLES (created idempotently at API startup by init_db.py;
|
||||||
|
-- defined here too so fresh installs have them from the start)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- M:N quotes ↔ projects (an offer can be about several projects)
|
||||||
|
CREATE TABLE quote_projects (
|
||||||
|
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (quote_id, project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- M:N line_items ↔ projects (offer-side allocation; the PURCHASED project
|
||||||
|
-- is the single item_decisions.project_id)
|
||||||
|
CREATE TABLE line_item_projects (
|
||||||
|
line_item_id UUID NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (line_item_id, project_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One reference unit per category (units dashboard)
|
||||||
|
CREATE TABLE category_reference_units (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||||
|
ref_unit TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Brand-scoped / category-scoped conversions
|
||||||
|
CREATE TABLE unit_conversions (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
||||||
|
brand TEXT,
|
||||||
|
from_unit TEXT NOT NULL,
|
||||||
|
to_unit TEXT NOT NULL,
|
||||||
|
factor NUMERIC(18,6) NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_unit_conversion
|
||||||
|
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit);
|
||||||
|
|
||||||
|
-- Global conversions, applies to all categories
|
||||||
|
CREATE TABLE standard_conversions (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
from_unit TEXT NOT NULL,
|
||||||
|
to_unit TEXT NOT NULL,
|
||||||
|
factor NUMERIC(18,6) NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_material_categories_updated_at
|
||||||
|
BEFORE UPDATE ON material_categories
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_materials_updated_at
|
||||||
|
BEFORE UPDATE ON materials
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_quotes_updated_at
|
||||||
|
BEFORE UPDATE ON quotes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_quote_attachments_updated_at
|
||||||
|
BEFORE UPDATE ON quote_attachments
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
|
||||||
|
-- Idempotent: only touches rows where category_id IS NULL.
|
||||||
|
CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE materials
|
||||||
|
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = NEW.material_id
|
||||||
|
AND category_id IS NULL;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||||
|
AFTER INSERT ON supplier_materials
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
|
||||||
|
|
||||||
|
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||||||
|
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||||||
|
-- inserts decisions directly as 'chosen').
|
||||||
|
CREATE OR REPLACE FUNCTION fn_sync_last_quoted_price()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'chosen' AND NEW.chosen_item_termsheet_id IS NOT NULL THEN
|
||||||
|
UPDATE supplier_materials sm
|
||||||
|
SET last_quoted_price = COALESCE(NEW.adjusted_unit_price, it.unit_price),
|
||||||
|
last_quoted_at = now()
|
||||||
|
FROM item_termsheets it
|
||||||
|
JOIN line_items li ON li.id = it.line_item_id
|
||||||
|
JOIN quotes q ON q.id = li.quote_id
|
||||||
|
WHERE it.id = NEW.chosen_item_termsheet_id
|
||||||
|
AND sm.supplier_id = q.supplier_id
|
||||||
|
AND sm.material_id = li.material_id;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_item_decisions_sync_price
|
||||||
|
AFTER INSERT OR UPDATE OF status ON item_decisions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price();
|
||||||
|
|
||||||
|
-- NOTE: inventory.quantity is maintained by the SERVICE layer
|
||||||
|
-- (inventory_service.apply_transaction) with SELECT ... FOR UPDATE row
|
||||||
|
-- locks — NOT a trigger — so the ledger and the snapshot can never be
|
||||||
|
-- double-applied. Arrival auto-posting (POST /pm/receipts) goes through
|
||||||
|
-- the same service path and is idempotent via receipt_allocation_id.
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user