-- ===================================================== -- SCHEMA: sales -- PROPÓSITO: Gestión de ventas, cotizaciones, clientes -- MÓDULOS: MGN-007 (Ventas Básico) -- FECHA: 2025-11-24 -- ===================================================== -- Crear schema CREATE SCHEMA IF NOT EXISTS sales; -- ===================================================== -- TYPES (ENUMs) -- ===================================================== CREATE TYPE sales.order_status AS ENUM ( 'draft', 'sent', 'sale', 'done', 'cancelled' ); CREATE TYPE sales.quotation_status AS ENUM ( 'draft', 'sent', 'approved', 'rejected', 'converted', 'expired' ); CREATE TYPE sales.invoice_policy AS ENUM ( 'order', 'delivery' ); CREATE TYPE sales.delivery_status AS ENUM ( 'pending', 'partial', 'delivered' ); CREATE TYPE sales.invoice_status AS ENUM ( 'pending', 'partial', 'invoiced' ); -- ===================================================== -- TABLES -- ===================================================== -- Tabla: sales_orders (Órdenes de venta) CREATE TABLE sales.sales_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, -- Numeración name VARCHAR(100) NOT NULL, client_order_ref VARCHAR(100), -- Referencia del cliente -- Cliente partner_id UUID NOT NULL REFERENCES core.partners(id), -- COR-010: Direcciones de facturación y envío separadas partner_invoice_id UUID REFERENCES core.partners(id), partner_shipping_id UUID REFERENCES core.partners(id), -- Fechas order_date DATE NOT NULL, validity_date DATE, commitment_date DATE, -- Configuración currency_id UUID NOT NULL REFERENCES core.currencies(id), pricelist_id UUID REFERENCES sales.pricelists(id), payment_term_id UUID REFERENCES financial.payment_terms(id), -- Usuario user_id UUID REFERENCES auth.users(id), sales_team_id UUID REFERENCES sales.sales_teams(id), -- Montos amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, -- Estado status sales.order_status NOT NULL DEFAULT 'draft', invoice_status sales.invoice_status NOT NULL DEFAULT 'pending', delivery_status sales.delivery_status NOT NULL DEFAULT 'pending', -- Facturación invoice_policy sales.invoice_policy DEFAULT 'order', -- Relaciones generadas picking_id UUID REFERENCES inventory.pickings(id), -- COR-006: Vinculación con facturas invoice_ids UUID[] DEFAULT '{}', invoice_count INTEGER DEFAULT 0, -- COR-011: Bloqueo de orden locked BOOLEAN DEFAULT FALSE, -- COR-012: Anticipos (Downpayments) require_signature BOOLEAN DEFAULT FALSE, require_payment BOOLEAN DEFAULT FALSE, prepayment_percent DECIMAL(5, 2) DEFAULT 0, -- Notas notes TEXT, terms_conditions TEXT, -- Firma electrónica signature TEXT, -- base64 signed_by VARCHAR(255), -- COR-012: Nombre del firmante signature_date TIMESTAMP, signature_ip INET, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), confirmed_at TIMESTAMP, confirmed_by UUID REFERENCES auth.users(id), cancelled_at TIMESTAMP, cancelled_by UUID REFERENCES auth.users(id), CONSTRAINT uq_sales_orders_name_company UNIQUE (company_id, name), CONSTRAINT chk_sales_orders_validity CHECK (validity_date IS NULL OR validity_date >= order_date) ); -- Tabla: sales_order_lines (Líneas de orden de venta) CREATE TABLE sales.sales_order_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, order_id UUID NOT NULL REFERENCES sales.sales_orders(id) ON DELETE CASCADE, product_id UUID NOT NULL REFERENCES inventory.products(id), description TEXT NOT NULL, -- Cantidades quantity DECIMAL(12, 4) NOT NULL, qty_delivered DECIMAL(12, 4) DEFAULT 0, qty_invoiced DECIMAL(12, 4) DEFAULT 0, uom_id UUID NOT NULL REFERENCES core.uom(id), -- Precios price_unit DECIMAL(15, 4) NOT NULL, discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento -- Impuestos tax_ids UUID[] DEFAULT '{}', -- Montos amount_untaxed DECIMAL(15, 2) NOT NULL, amount_tax DECIMAL(15, 2) NOT NULL, amount_total DECIMAL(15, 2) NOT NULL, -- Analítica analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica -- COR-012: Soporte para anticipos is_downpayment BOOLEAN DEFAULT FALSE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, CONSTRAINT chk_sales_order_lines_quantity CHECK (quantity > 0), CONSTRAINT chk_sales_order_lines_discount CHECK (discount >= 0 AND discount <= 100), CONSTRAINT chk_sales_order_lines_qty_delivered CHECK (qty_delivered >= 0 AND qty_delivered <= quantity), CONSTRAINT chk_sales_order_lines_qty_invoiced CHECK (qty_invoiced >= 0 AND qty_invoiced <= quantity) ); -- Índices para sales_order_lines CREATE INDEX idx_sales_order_lines_tenant_id ON sales.sales_order_lines(tenant_id); CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id); CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id); -- RLS para sales_order_lines ALTER TABLE sales.sales_order_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_sales_order_lines ON sales.sales_order_lines USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: quotations (Cotizaciones) CREATE TABLE sales.quotations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, -- Numeración name VARCHAR(100) NOT NULL, -- Cliente potencial partner_id UUID NOT NULL REFERENCES core.partners(id), -- Fechas quotation_date DATE NOT NULL, validity_date DATE NOT NULL, -- Configuración currency_id UUID NOT NULL REFERENCES core.currencies(id), pricelist_id UUID REFERENCES sales.pricelists(id), -- Usuario user_id UUID REFERENCES auth.users(id), sales_team_id UUID REFERENCES sales.sales_teams(id), -- Montos amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, -- Estado status sales.quotation_status NOT NULL DEFAULT 'draft', -- Conversión sale_order_id UUID REFERENCES sales.sales_orders(id), -- Orden generada -- Notas notes TEXT, terms_conditions TEXT, -- Firma electrónica signature TEXT, -- base64 signature_date TIMESTAMP, signature_ip INET, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_quotations_name_company UNIQUE (company_id, name), CONSTRAINT chk_quotations_validity CHECK (validity_date >= quotation_date) ); -- Tabla: quotation_lines (Líneas de cotización) CREATE TABLE sales.quotation_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, quotation_id UUID NOT NULL REFERENCES sales.quotations(id) ON DELETE CASCADE, product_id UUID REFERENCES inventory.products(id), description TEXT NOT NULL, quantity DECIMAL(12, 4) NOT NULL, uom_id UUID NOT NULL REFERENCES core.uom(id), price_unit DECIMAL(15, 4) NOT NULL, discount DECIMAL(5, 2) DEFAULT 0, tax_ids UUID[] DEFAULT '{}', amount_untaxed DECIMAL(15, 2) NOT NULL, amount_tax DECIMAL(15, 2) NOT NULL, amount_total DECIMAL(15, 2) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_quotation_lines_quantity CHECK (quantity > 0), CONSTRAINT chk_quotation_lines_discount CHECK (discount >= 0 AND discount <= 100) ); -- Índices para quotation_lines CREATE INDEX idx_quotation_lines_tenant_id ON sales.quotation_lines(tenant_id); CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id); CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id); -- RLS para quotation_lines ALTER TABLE sales.quotation_lines ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_quotation_lines ON sales.quotation_lines USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: pricelists (Listas de precios) CREATE TABLE sales.pricelists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID REFERENCES auth.companies(id), name VARCHAR(255) NOT NULL, currency_id UUID NOT NULL REFERENCES core.currencies(id), -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_pricelists_name_tenant UNIQUE (tenant_id, name) ); -- Tabla: pricelist_items (Items de lista de precios) CREATE TABLE sales.pricelist_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, pricelist_id UUID NOT NULL REFERENCES sales.pricelists(id) ON DELETE CASCADE, product_id UUID REFERENCES inventory.products(id), product_category_id UUID REFERENCES core.product_categories(id), -- Precio price DECIMAL(15, 4) NOT NULL, -- Cantidad mínima min_quantity DECIMAL(12, 4) DEFAULT 1, -- Validez valid_from DATE, valid_to DATE, -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT chk_pricelist_items_price CHECK (price >= 0), CONSTRAINT chk_pricelist_items_min_qty CHECK (min_quantity > 0), CONSTRAINT chk_pricelist_items_dates CHECK (valid_to IS NULL OR valid_to >= valid_from), CONSTRAINT chk_pricelist_items_product_or_category CHECK ( (product_id IS NOT NULL AND product_category_id IS NULL) OR (product_id IS NULL AND product_category_id IS NOT NULL) ) ); -- Índices para pricelist_items CREATE INDEX idx_pricelist_items_tenant_id ON sales.pricelist_items(tenant_id); CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id); CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id); -- RLS para pricelist_items ALTER TABLE sales.pricelist_items ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_pricelist_items ON sales.pricelist_items USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Tabla: customer_groups (Grupos de clientes) CREATE TABLE sales.customer_groups ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, description TEXT, discount_percentage DECIMAL(5, 2) DEFAULT 0, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), CONSTRAINT uq_customer_groups_name_tenant UNIQUE (tenant_id, name), CONSTRAINT chk_customer_groups_discount CHECK (discount_percentage >= 0 AND discount_percentage <= 100) ); -- Tabla: customer_group_members (Miembros de grupos) CREATE TABLE sales.customer_group_members ( customer_group_id UUID NOT NULL REFERENCES sales.customer_groups(id) ON DELETE CASCADE, partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (customer_group_id, partner_id) ); -- Tabla: sales_teams (Equipos de ventas) CREATE TABLE sales.sales_teams ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, code VARCHAR(50), team_leader_id UUID REFERENCES auth.users(id), -- Objetivos target_monthly DECIMAL(15, 2), target_annual DECIMAL(15, 2), -- Control active BOOLEAN NOT NULL DEFAULT TRUE, -- Auditoría created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMP, updated_by UUID REFERENCES auth.users(id), CONSTRAINT uq_sales_teams_code_company UNIQUE (company_id, code) ); -- Tabla: sales_team_members (Miembros de equipos) CREATE TABLE sales.sales_team_members ( sales_team_id UUID NOT NULL REFERENCES sales.sales_teams(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (sales_team_id, user_id) ); -- ===================================================== -- INDICES -- ===================================================== -- Sales Orders CREATE INDEX idx_sales_orders_tenant_id ON sales.sales_orders(tenant_id); CREATE INDEX idx_sales_orders_company_id ON sales.sales_orders(company_id); CREATE INDEX idx_sales_orders_partner_id ON sales.sales_orders(partner_id); CREATE INDEX idx_sales_orders_name ON sales.sales_orders(name); CREATE INDEX idx_sales_orders_status ON sales.sales_orders(status); CREATE INDEX idx_sales_orders_order_date ON sales.sales_orders(order_date); CREATE INDEX idx_sales_orders_user_id ON sales.sales_orders(user_id); CREATE INDEX idx_sales_orders_sales_team_id ON sales.sales_orders(sales_team_id); -- Sales Order Lines CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id); CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id); CREATE INDEX idx_sales_order_lines_analytic_account_id ON sales.sales_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL; -- Quotations CREATE INDEX idx_quotations_tenant_id ON sales.quotations(tenant_id); CREATE INDEX idx_quotations_company_id ON sales.quotations(company_id); CREATE INDEX idx_quotations_partner_id ON sales.quotations(partner_id); CREATE INDEX idx_quotations_status ON sales.quotations(status); CREATE INDEX idx_quotations_validity_date ON sales.quotations(validity_date); -- Quotation Lines CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id); CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id); -- Pricelists CREATE INDEX idx_pricelists_tenant_id ON sales.pricelists(tenant_id); CREATE INDEX idx_pricelists_active ON sales.pricelists(active) WHERE active = TRUE; -- Pricelist Items CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id); CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id); CREATE INDEX idx_pricelist_items_category_id ON sales.pricelist_items(product_category_id); -- Customer Groups CREATE INDEX idx_customer_groups_tenant_id ON sales.customer_groups(tenant_id); -- Sales Teams CREATE INDEX idx_sales_teams_tenant_id ON sales.sales_teams(tenant_id); CREATE INDEX idx_sales_teams_company_id ON sales.sales_teams(company_id); CREATE INDEX idx_sales_teams_leader_id ON sales.sales_teams(team_leader_id); -- ===================================================== -- FUNCTIONS -- ===================================================== -- Función: calculate_sales_order_totals CREATE OR REPLACE FUNCTION sales.calculate_sales_order_totals(p_order_id UUID) RETURNS VOID AS $$ DECLARE v_amount_untaxed DECIMAL; v_amount_tax DECIMAL; v_amount_total DECIMAL; BEGIN SELECT COALESCE(SUM(amount_untaxed), 0), COALESCE(SUM(amount_tax), 0), COALESCE(SUM(amount_total), 0) INTO v_amount_untaxed, v_amount_tax, v_amount_total FROM sales.sales_order_lines WHERE order_id = p_order_id; UPDATE sales.sales_orders SET amount_untaxed = v_amount_untaxed, amount_tax = v_amount_tax, amount_total = v_amount_total, updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_order_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION sales.calculate_sales_order_totals IS 'Calcula los totales de una orden de venta'; -- Función: calculate_quotation_totals CREATE OR REPLACE FUNCTION sales.calculate_quotation_totals(p_quotation_id UUID) RETURNS VOID AS $$ DECLARE v_amount_untaxed DECIMAL; v_amount_tax DECIMAL; v_amount_total DECIMAL; BEGIN SELECT COALESCE(SUM(amount_untaxed), 0), COALESCE(SUM(amount_tax), 0), COALESCE(SUM(amount_total), 0) INTO v_amount_untaxed, v_amount_tax, v_amount_total FROM sales.quotation_lines WHERE quotation_id = p_quotation_id; UPDATE sales.quotations SET amount_untaxed = v_amount_untaxed, amount_tax = v_amount_tax, amount_total = v_amount_total, updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_quotation_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION sales.calculate_quotation_totals IS 'Calcula los totales de una cotización'; -- Función: convert_quotation_to_order CREATE OR REPLACE FUNCTION sales.convert_quotation_to_order(p_quotation_id UUID) RETURNS UUID AS $$ DECLARE v_quotation RECORD; v_order_id UUID; BEGIN -- Obtener cotización SELECT * INTO v_quotation FROM sales.quotations WHERE id = p_quotation_id; IF NOT FOUND THEN RAISE EXCEPTION 'Quotation % not found', p_quotation_id; END IF; IF v_quotation.status != 'approved' THEN RAISE EXCEPTION 'Quotation must be approved before conversion'; END IF; -- Crear orden de venta INSERT INTO sales.sales_orders ( tenant_id, company_id, name, partner_id, order_date, currency_id, pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax, amount_total, notes, terms_conditions, signature, signature_date, signature_ip ) VALUES ( v_quotation.tenant_id, v_quotation.company_id, REPLACE(v_quotation.name, 'QT', 'SO'), v_quotation.partner_id, CURRENT_DATE, v_quotation.currency_id, v_quotation.pricelist_id, v_quotation.user_id, v_quotation.sales_team_id, v_quotation.amount_untaxed, v_quotation.amount_tax, v_quotation.amount_total, v_quotation.notes, v_quotation.terms_conditions, v_quotation.signature, v_quotation.signature_date, v_quotation.signature_ip ) RETURNING id INTO v_order_id; -- Copiar líneas INSERT INTO sales.sales_order_lines ( order_id, product_id, description, quantity, uom_id, price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total ) SELECT v_order_id, product_id, description, quantity, uom_id, price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total FROM sales.quotation_lines WHERE quotation_id = p_quotation_id; -- Actualizar cotización UPDATE sales.quotations SET status = 'converted', sale_order_id = v_order_id, updated_at = CURRENT_TIMESTAMP, updated_by = get_current_user_id() WHERE id = p_quotation_id; RETURN v_order_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION sales.convert_quotation_to_order IS 'Convierte una cotización aprobada en orden de venta'; -- ===================================================== -- TRIGGERS -- ===================================================== CREATE TRIGGER trg_sales_orders_updated_at BEFORE UPDATE ON sales.sales_orders FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_quotations_updated_at BEFORE UPDATE ON sales.quotations FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_pricelists_updated_at BEFORE UPDATE ON sales.pricelists FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); CREATE TRIGGER trg_sales_teams_updated_at BEFORE UPDATE ON sales.sales_teams FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); -- Trigger: Actualizar totales de orden al cambiar líneas CREATE OR REPLACE FUNCTION sales.trg_update_so_totals() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'DELETE' THEN PERFORM sales.calculate_sales_order_totals(OLD.order_id); ELSE PERFORM sales.calculate_sales_order_totals(NEW.order_id); END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_sales_order_lines_update_totals AFTER INSERT OR UPDATE OR DELETE ON sales.sales_order_lines FOR EACH ROW EXECUTE FUNCTION sales.trg_update_so_totals(); -- Trigger: Actualizar totales de cotización al cambiar líneas CREATE OR REPLACE FUNCTION sales.trg_update_quotation_totals() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'DELETE' THEN PERFORM sales.calculate_quotation_totals(OLD.quotation_id); ELSE PERFORM sales.calculate_quotation_totals(NEW.quotation_id); END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_quotation_lines_update_totals AFTER INSERT OR UPDATE OR DELETE ON sales.quotation_lines FOR EACH ROW EXECUTE FUNCTION sales.trg_update_quotation_totals(); -- ===================================================== -- TRACKING AUTOMÁTICO (mail.thread pattern) -- ===================================================== -- Trigger: Tracking automático para órdenes de venta CREATE TRIGGER track_sales_order_changes AFTER INSERT OR UPDATE OR DELETE ON sales.sales_orders FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); COMMENT ON TRIGGER track_sales_order_changes ON sales.sales_orders IS 'Registra automáticamente cambios en órdenes de venta (estado, cliente, monto, fecha, facturación, entrega)'; -- ===================================================== -- ROW LEVEL SECURITY (RLS) -- ===================================================== ALTER TABLE sales.sales_orders ENABLE ROW LEVEL SECURITY; ALTER TABLE sales.quotations ENABLE ROW LEVEL SECURITY; ALTER TABLE sales.pricelists ENABLE ROW LEVEL SECURITY; ALTER TABLE sales.customer_groups ENABLE ROW LEVEL SECURITY; ALTER TABLE sales.sales_teams ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_sales_orders ON sales.sales_orders USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_quotations ON sales.quotations USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_pricelists ON sales.pricelists USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_customer_groups ON sales.customer_groups USING (tenant_id = get_current_tenant_id()); CREATE POLICY tenant_isolation_sales_teams ON sales.sales_teams USING (tenant_id = get_current_tenant_id()); -- ===================================================== -- COMENTARIOS -- ===================================================== COMMENT ON SCHEMA sales IS 'Schema de gestión de ventas, cotizaciones y clientes'; COMMENT ON TABLE sales.sales_orders IS 'Órdenes de venta confirmadas'; COMMENT ON TABLE sales.sales_order_lines IS 'Líneas de órdenes de venta'; COMMENT ON TABLE sales.quotations IS 'Cotizaciones enviadas a clientes'; COMMENT ON TABLE sales.quotation_lines IS 'Líneas de cotizaciones'; COMMENT ON TABLE sales.pricelists IS 'Listas de precios para clientes'; COMMENT ON TABLE sales.pricelist_items IS 'Items de listas de precios por producto/categoría'; COMMENT ON TABLE sales.customer_groups IS 'Grupos de clientes para descuentos y segmentación'; COMMENT ON TABLE sales.sales_teams IS 'Equipos de ventas con objetivos'; -- ===================================================== -- COR-033: Sales Order Templates -- Equivalente a sale.order.template de Odoo -- ===================================================== CREATE TABLE sales.order_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, note TEXT, number_of_days INTEGER DEFAULT 0, require_signature BOOLEAN DEFAULT FALSE, require_payment BOOLEAN DEFAULT FALSE, prepayment_percent DECIMAL(5,2) DEFAULT 0, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sales.order_template_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), template_id UUID NOT NULL REFERENCES sales.order_templates(id) ON DELETE CASCADE, sequence INTEGER DEFAULT 10, product_id UUID REFERENCES inventory.products(id), name TEXT, quantity DECIMAL(20,6) DEFAULT 1, product_uom_id UUID REFERENCES core.uom(id), display_type VARCHAR(20) -- line_section, line_note ); CREATE INDEX idx_order_templates_tenant ON sales.order_templates(tenant_id); CREATE INDEX idx_order_template_lines_template ON sales.order_template_lines(template_id); -- RLS ALTER TABLE sales.order_templates ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_order_templates ON sales.order_templates USING (tenant_id = get_current_tenant_id()); COMMENT ON TABLE sales.order_templates IS 'COR-033: Sale order templates - Equivalent to sale.order.template'; COMMENT ON TABLE sales.order_template_lines IS 'COR-033: Sale order template lines'; -- ===================================================== -- COR-048: Sales Order Additional Fields -- Campos adicionales para alinear con Odoo -- ===================================================== -- Agregar campos a sales_orders ALTER TABLE sales.sales_orders ADD COLUMN IF NOT EXISTS incoterm_id UUID, -- FK to financial.incoterms ADD COLUMN IF NOT EXISTS incoterm_location VARCHAR(255), ADD COLUMN IF NOT EXISTS fiscal_position_id UUID, -- FK to financial.fiscal_positions ADD COLUMN IF NOT EXISTS origin VARCHAR(255), -- Documento origen ADD COLUMN IF NOT EXISTS campaign_id UUID, -- FK to marketing campaigns ADD COLUMN IF NOT EXISTS medium_id UUID, -- FK to utm.medium ADD COLUMN IF NOT EXISTS source_id UUID, -- FK to utm.source ADD COLUMN IF NOT EXISTS opportunity_id UUID, -- FK to crm.opportunities ADD COLUMN IF NOT EXISTS date_order TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, ADD COLUMN IF NOT EXISTS amount_undiscounted DECIMAL(20,6), -- Amount before discount ADD COLUMN IF NOT EXISTS amount_to_invoice DECIMAL(20,6), -- Pending to invoice ADD COLUMN IF NOT EXISTS amount_invoiced DECIMAL(20,6); -- Already invoiced -- Agregar campos a sales_order_lines ALTER TABLE sales.sales_order_lines ADD COLUMN IF NOT EXISTS sequence INTEGER DEFAULT 10, ADD COLUMN IF NOT EXISTS display_type VARCHAR(20), -- line_section, line_note ADD COLUMN IF NOT EXISTS qty_to_invoice DECIMAL(20,6) GENERATED ALWAYS AS (quantity - qty_invoiced) STORED, ADD COLUMN IF NOT EXISTS qty_to_deliver DECIMAL(20,6) GENERATED ALWAYS AS (quantity - qty_delivered) STORED, ADD COLUMN IF NOT EXISTS product_packaging_id UUID, ADD COLUMN IF NOT EXISTS product_packaging_qty DECIMAL(20,6), ADD COLUMN IF NOT EXISTS price_reduce DECIMAL(20,6), -- Price after discount ADD COLUMN IF NOT EXISTS price_reduce_taxexcl DECIMAL(20,6), ADD COLUMN IF NOT EXISTS price_reduce_taxinc DECIMAL(20,6), ADD COLUMN IF NOT EXISTS customer_lead INTEGER DEFAULT 0, -- Dias de entrega al cliente ADD COLUMN IF NOT EXISTS route_id UUID; -- FK to inventory.routes CREATE INDEX idx_sales_orders_origin ON sales.sales_orders(origin); CREATE INDEX idx_sales_orders_opportunity ON sales.sales_orders(opportunity_id); CREATE INDEX idx_sales_order_lines_sequence ON sales.sales_order_lines(order_id, sequence); COMMENT ON COLUMN sales.sales_orders.incoterm_id IS 'COR-048: Incoterm reference'; COMMENT ON COLUMN sales.sales_orders.origin IS 'COR-048: Source document reference'; COMMENT ON COLUMN sales.sales_order_lines.qty_to_invoice IS 'COR-048: Computed quantity to invoice'; -- ===================================================== -- COR-049: Sales Action Confirm -- Funcion para confirmar SO y crear delivery -- ===================================================== CREATE OR REPLACE FUNCTION sales.action_confirm(p_order_id UUID) RETURNS UUID AS $$ DECLARE v_order RECORD; v_line RECORD; v_picking_id UUID; v_move_id UUID; v_location_stock UUID; v_location_customer UUID; BEGIN -- Obtener orden SELECT * INTO v_order FROM sales.sales_orders WHERE id = p_order_id; IF NOT FOUND THEN RAISE EXCEPTION 'Sales order % not found', p_order_id; END IF; IF v_order.status NOT IN ('draft', 'sent') THEN RAISE EXCEPTION 'Sales order % cannot be confirmed from status %', p_order_id, v_order.status; END IF; -- Obtener ubicaciones SELECT id INTO v_location_stock FROM inventory.locations WHERE location_type = 'internal' AND tenant_id = v_order.tenant_id LIMIT 1; SELECT id INTO v_location_customer FROM inventory.locations WHERE location_type = 'customer' AND tenant_id = v_order.tenant_id LIMIT 1; -- Crear picking de salida INSERT INTO inventory.pickings ( tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, origin, scheduled_date, status ) VALUES ( v_order.tenant_id, v_order.company_id, 'OUT/' || v_order.name, 'outgoing', v_location_stock, v_location_customer, v_order.partner_id, v_order.name, COALESCE(v_order.commitment_date, CURRENT_DATE + 1), 'draft' ) RETURNING id INTO v_picking_id; -- Crear stock moves para cada linea FOR v_line IN SELECT * FROM sales.sales_order_lines WHERE order_id = p_order_id AND display_type IS NULL LOOP INSERT INTO inventory.stock_moves ( tenant_id, picking_id, product_id, product_uom_id, product_qty, location_id, location_dest_id, origin, state, name ) VALUES ( v_order.tenant_id, v_picking_id, v_line.product_id, v_line.uom_id, v_line.quantity, v_location_stock, v_location_customer, v_order.name, 'draft', v_line.description ) RETURNING id INTO v_move_id; END LOOP; -- Actualizar orden UPDATE sales.sales_orders SET status = 'sale', picking_id = v_picking_id, confirmed_at = NOW(), updated_at = NOW() WHERE id = p_order_id; RETURN v_picking_id; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION sales.action_confirm IS 'COR-049: Confirm sales order and create delivery picking'; -- ===================================================== -- COR-050: Product Pricelist Compute -- Funcion para calcular precio desde pricelist -- ===================================================== CREATE OR REPLACE FUNCTION sales.get_pricelist_price( p_pricelist_id UUID, p_product_id UUID, p_quantity DECIMAL, p_date DATE DEFAULT CURRENT_DATE ) RETURNS DECIMAL AS $$ DECLARE v_price DECIMAL; v_item RECORD; BEGIN -- Buscar precio en pricelist items SELECT * INTO v_item FROM sales.pricelist_items WHERE pricelist_id = p_pricelist_id AND product_id = p_product_id AND active = TRUE AND min_quantity <= p_quantity AND (valid_from IS NULL OR valid_from <= p_date) AND (valid_to IS NULL OR valid_to >= p_date) ORDER BY min_quantity DESC LIMIT 1; IF FOUND THEN RETURN v_item.price; END IF; -- Buscar por categoria SELECT pi.price INTO v_price FROM sales.pricelist_items pi JOIN inventory.products p ON p.category_id = pi.product_category_id WHERE pi.pricelist_id = p_pricelist_id AND p.id = p_product_id AND pi.active = TRUE AND pi.min_quantity <= p_quantity AND (pi.valid_from IS NULL OR pi.valid_from <= p_date) AND (pi.valid_to IS NULL OR pi.valid_to >= p_date) ORDER BY pi.min_quantity DESC LIMIT 1; IF v_price IS NOT NULL THEN RETURN v_price; END IF; -- Retornar precio del producto SELECT list_price INTO v_price FROM inventory.products WHERE id = p_product_id; RETURN COALESCE(v_price, 0); END; $$ LANGUAGE plpgsql STABLE; COMMENT ON FUNCTION sales.get_pricelist_price IS 'COR-050: Get product price from pricelist'; -- ===================================================== -- FIN DEL SCHEMA SALES -- =====================================================