FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica

FASE 4:
- Redis cache de stock con fallback graceful
- Multi-moneda (MXN/USD) con contabilidad en MXN
- Proveedores y ordenes de compra completo
- Meilisearch 1.5M+ partes indexadas
- Metabase KPIs con dashboard auto-generado

FASE 5:
- CRM mejorado: activities, tags, loyalty program, analytics
- Imagenes de partes: upload, resize, thumbnails WebP
- Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered
- Garantias/RMA, alertas de reorden, multi-sucursal
- Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi)

FASE 6:
- Notificaciones automaticas: push/WhatsApp/email/in-app
- Reportes de ahorro vs retail_price
- Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber
- API Publica: API keys, rate limiting, catalog search

Migraciones: v1.9-v3.0
Tests: 93/93 pasando
Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -0,0 +1,82 @@
-- v2.9 Logistics & Shipment Tracking
-- Couriers / carriers
CREATE TABLE IF NOT EXISTS couriers (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name VARCHAR(100) NOT NULL, -- 'DHL', 'FedEx', 'Estafeta', '99minutos', 'Uber Direct'
code VARCHAR(20) NOT NULL, -- internal code
tracking_url_template VARCHAR(500), -- e.g. "https://www.dhl.com/track?trackingNumber={tracking_number}"
api_endpoint VARCHAR(500),
api_key_encrypted TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_couriers_code ON couriers(tenant_id, code);
-- Shipments
CREATE TABLE IF NOT EXISTS shipments (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
branch_id INTEGER REFERENCES branches(id),
shipment_type VARCHAR(20) DEFAULT 'outbound', -- outbound, inbound, return
related_type VARCHAR(50), -- 'sale', 'purchase_order', 'service_order', 'marketplace_order'
related_id INTEGER,
courier_id INTEGER REFERENCES couriers(id),
tracking_number VARCHAR(100),
tracking_url VARCHAR(500),
status VARCHAR(30) DEFAULT 'pending', -- pending, label_created, picked_up, in_transit, out_for_delivery, delivered, failed, returned
origin_address TEXT,
destination_address TEXT,
recipient_name VARCHAR(200),
recipient_phone VARCHAR(50),
estimated_delivery DATE,
actual_delivery TIMESTAMPTZ,
shipping_cost NUMERIC(12,2) DEFAULT 0,
weight_kg NUMERIC(8,3),
dimensions_cm VARCHAR(50), -- "30x20x15"
notes TEXT,
created_by INTEGER REFERENCES employees(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shipments_tracking ON shipments(tracking_number);
CREATE INDEX IF NOT EXISTS idx_shipments_status ON shipments(status);
CREATE INDEX IF NOT EXISTS idx_shipments_related ON shipments(related_type, related_id);
-- Shipment tracking history
CREATE TABLE IF NOT EXISTS shipment_tracking (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES shipments(id) ON DELETE CASCADE,
status VARCHAR(30) NOT NULL,
location VARCHAR(200),
description TEXT,
raw_response JSONB,
tracked_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shipment_tracking_shipment ON shipment_tracking(shipment_id, tracked_at DESC);
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_shipment_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_shipments_updated_at ON shipments;
CREATE TRIGGER trg_shipments_updated_at
BEFORE UPDATE ON shipments
FOR EACH ROW
EXECUTE FUNCTION update_shipment_updated_at();
-- Insert default couriers
INSERT INTO couriers (tenant_id, name, code, tracking_url_template, is_active) VALUES
(1, 'DHL Express', 'dhl', 'https://www.dhl.com/mx/es/home/tracking/tracking-ecommerce.html?tracking-id={tracking_number}', true),
(1, 'FedEx', 'fedex', 'https://www.fedex.com/apps/fedextrack/?tracknumbers={tracking_number}', true),
(1, 'Estafeta', 'estafeta', 'https://www.estafeta.com/herramientas/rastreo?guias={tracking_number}', true),
(1, '99 Minutos', '99minutos', 'https://99minutos.com/track/{tracking_number}', true),
(1, 'Uber Direct', 'uber_direct', NULL, true),
(1, 'Recolección en tienda', 'pickup', NULL, true)
ON CONFLICT DO NOTHING;