Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
2026-10-12 00:57:31 +00:00
parent f2d4558b80
commit a386ca5b4a
165 changed files with 31313 additions and 2203 deletions

View File

@@ -70,6 +70,11 @@ MIGRATIONS = {
"v4.21": "v4.21_cashier_workshop_permissions.sql",
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
"v4.23": "v4.23_service_order_mechanic_name.sql",
"v4.24": "v4.24_trgm_search_indexes.sql",
"v4.25": "v4.25_transfer_documents.sql",
"v4.26": "v4.26_sales_client_uuid.sql",
"v4.27": "v4.27_yonke_m1.sql",
"v4.28": "v4.28_meli_commerce.sql",
}

View File

@@ -1,3 +1,4 @@
-- : SKIP
-- v1.2: Add subdomain column to tenants table (nexus_master)
-- This migration runs against the MASTER database, not tenant databases.

View File

@@ -1,3 +1,4 @@
-- : SKIP
-- Marketplace orders (cross-tenant)
-- These tables go in nexus_autoparts (master DB), not tenant DBs

View File

@@ -2,15 +2,20 @@
-- Applies to: master DB (nexus_autoparts) and all tenant DBs
-- ─── MASTER DB ─────────────────────────────────
-- These indexes only exist in the master DB (nexus_autoparts). On tenant DBs
-- (the `parts`/`warehouse_inventory` OEM tables live only in master) they are
-- skipped via existence guards so this migration can run against tenants too.
-- Index for parts name lookups (used by get_part_types, shop supplies)
CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part);
CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops);
-- Partial index for warehouse_inventory stock lookups (already created in Fase 1,
-- kept here for completeness on fresh installs)
CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive
ON warehouse_inventory(part_id) WHERE stock_quantity > 0;
DO $$
BEGIN
IF to_regclass('public.parts') IS NOT NULL THEN
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part)';
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops)';
END IF;
IF to_regclass('public.warehouse_inventory') IS NOT NULL THEN
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive ON warehouse_inventory(part_id) WHERE stock_quantity > 0';
END IF;
END $$;
-- ─── TENANT DB (run on each tenant) ────────────

View File

@@ -10,9 +10,12 @@
-- To apply manually on a tenant with WhatsApp tables:
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
-- (Remove the ': SKIP' line above before manual execution.)
--
-- 2026-10-11: fixed nested dollar-quoting (the outer DO used $$ and the inner
-- trigger function also used $$, which broke the parser). Now $do$ / $fn$.
-- ============================================================
DO $$
DO $do$
BEGIN
-- 1. Extender whatsapp_sessions con estado y contexto
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
@@ -41,12 +44,12 @@ BEGIN
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
RETURNS TRIGGER AS $$
RETURNS TRIGGER AS $fn$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
$fn$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
CREATE TRIGGER trg_wa_link_updated
@@ -115,4 +118,4 @@ BEGIN
FROM wa_customer_links wcl
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
END IF;
END $$;
END $do$;

View File

@@ -0,0 +1,18 @@
-- v4.24: pg_trgm search indexes for fast fuzzy inventory search.
-- The POS list endpoint uses ILIKE '%q%' on part_number/name/barcode/aliases;
-- btree indexes cannot serve those. Trigram GIN indexes make them fast even
-- with 150k+ SKU catalogs.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_inventory_part_trgm
ON inventory USING gin (part_number gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_inventory_name_trgm
ON inventory USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_inventory_alias_trgm
ON inventory_sku_aliases USING gin (sku gin_trgm_ops);
ANALYZE inventory;
ANALYZE inventory_sku_aliases;

View File

@@ -0,0 +1,28 @@
-- v4.25: transfer documents with in-transit state.
-- Transfers become documents: draft -> sent -> received (or cancelled).
-- Actual stock movement happens on 'receive' (origin -, destination +),
-- so goods in transit are tracked and discrepancies are explicit.
CREATE TABLE IF NOT EXISTS inventory_transfers (
id SERIAL PRIMARY KEY,
from_branch_id INTEGER NOT NULL REFERENCES branches(id),
to_branch_id INTEGER NOT NULL REFERENCES branches(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | sent | received | cancelled
notes TEXT,
employee_id INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
sent_at TIMESTAMPTZ,
received_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS inventory_transfer_lines (
id SERIAL PRIMARY KEY,
transfer_id INTEGER NOT NULL REFERENCES inventory_transfers(id) ON DELETE CASCADE,
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
quantity INTEGER NOT NULL,
qty_received INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_transfers_status ON inventory_transfers(status);
CREATE INDEX IF NOT EXISTS idx_transfer_lines_transfer ON inventory_transfer_lines(transfer_id);

View File

@@ -0,0 +1,4 @@
-- v4.26: idempotencia para venta offline (client_uuid generado por el POS).
ALTER TABLE sales ADD COLUMN IF NOT EXISTS client_uuid VARCHAR(64);
CREATE UNIQUE INDEX IF NOT EXISTS ux_sales_client_uuid
ON sales (client_uuid) WHERE client_uuid IS NOT NULL;

View File

@@ -0,0 +1,113 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- v4.27 — Yonke M1: used-parts (parts-usadas) data model
-- Additive + idempotent. Runs against tenant DBs.
--
-- Enables businesses that sell ONLY used parts (yonkes/deshuesaderos):
-- * condition (nuevo/usado/reconstruido) + grade (A/B/C/D)
-- * unique 1-of-1 pieces (is_unique_piece) with a donor vehicle
-- * multiple photos per item (inventory_images)
-- * ownership (propio/consigna) + consignment tables
-- * warranty expressed in days (for used parts)
-- ═══════════════════════════════════════════════════════════════════════════
-- ─── 1. inventory: condition / donor / ownership / unique-piece columns ─────
ALTER TABLE inventory
ADD COLUMN IF NOT EXISTS condition VARCHAR(20) DEFAULT 'nuevo',
ADD COLUMN IF NOT EXISTS condition_grade VARCHAR(2),
ADD COLUMN IF NOT EXISTS is_unique_piece BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS donor_vin VARCHAR(20),
ADD COLUMN IF NOT EXISTS donor_make VARCHAR(60),
ADD COLUMN IF NOT EXISTS donor_model VARCHAR(60),
ADD COLUMN IF NOT EXISTS donor_year INTEGER,
ADD COLUMN IF NOT EXISTS donor_mileage_km INTEGER,
ADD COLUMN IF NOT EXISTS ownership VARCHAR(20) DEFAULT 'propio',
ADD COLUMN IF NOT EXISTS acquisition_cost NUMERIC(12,2);
-- CHECK constraints (guarded so re-runs are safe)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_condition_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_condition_chk
CHECK (condition IS NULL OR condition IN ('nuevo','usado','reconstruido'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_ownership_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_ownership_chk
CHECK (ownership IS NULL OR ownership IN ('propio','consigna'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_grade_chk') THEN
ALTER TABLE inventory ADD CONSTRAINT inventory_grade_chk
CHECK (condition_grade IS NULL OR condition_grade IN ('A','B','C','D'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_inventory_condition
ON inventory(condition);
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vin
ON inventory(donor_vin) WHERE donor_vin IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vehicle
ON inventory(donor_make, donor_model, donor_year) WHERE is_unique_piece;
-- ─── 2. Relax part_number NOT NULL so codeless unique pieces can exist ──────
-- A standard UNIQUE index in PostgreSQL already treats NULLs as distinct, so
-- the existing full unique index (idx_inventory_part_unique) keeps enforcing
-- uniqueness on non-null codes while allowing many NULLs. We deliberately do
-- NOT turn it into a partial index: that would break `ON CONFLICT (part_number)`
-- used by the importers.
ALTER TABLE inventory ALTER COLUMN part_number DROP NOT NULL;
-- ─── 3. inventory_images: multiple photos per item ─────────────────────────
CREATE TABLE IF NOT EXISTS inventory_images (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_inventory_images_inv
ON inventory_images(inventory_id, sort_order);
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_images_primary
ON inventory_images(inventory_id) WHERE is_primary;
-- Migrate the existing single image_url into the new table (first photo).
INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary)
SELECT i.id, i.image_url, 0, TRUE
FROM inventory i
WHERE i.image_url IS NOT NULL AND i.image_url <> ''
AND NOT EXISTS (SELECT 1 FROM inventory_images ii WHERE ii.inventory_id = i.id);
-- ─── 4. warranties: express warranty in days (used parts) ──────────────────
DO $$
BEGIN
IF to_regclass('public.warranties') IS NOT NULL THEN
EXECUTE 'ALTER TABLE warranties ADD COLUMN IF NOT EXISTS warranty_days INTEGER';
END IF;
END $$;
-- ─── 5. Consignments (third-party parts held for sale) ─────────────────────
CREATE TABLE IF NOT EXISTS consignments (
id SERIAL PRIMARY KEY,
consignor_name VARCHAR(200) NOT NULL,
consignor_rfc VARCHAR(13),
consignor_phone VARCHAR(30),
commission_pct NUMERIC(5,2) DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'open', -- open | closed | settled
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS consignment_items (
id SERIAL PRIMARY KEY,
consignment_id INTEGER NOT NULL REFERENCES consignments(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
agreed_price NUMERIC(12,2),
commission_amount NUMERIC(12,2),
status VARCHAR(20) NOT NULL DEFAULT 'in_stock', -- in_stock | sold | returned | settled
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_consignment_items_cons
ON consignment_items(consignment_id);
CREATE INDEX IF NOT EXISTS idx_consignment_items_inv
ON consignment_items(inventory_id);

View File

@@ -0,0 +1,56 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- v4.28 — MeLi commerce: listing economics, inventory-match metadata
-- Additive + idempotent. Runs against tenant DBs.
--
-- Goal: make MercadoLibre listings sellable end-to-end by persisting, per
-- listing, the data that today is only computed transiently:
-- * how the ML item was linked to local inventory (backfill audit)
-- * the ML commission and the resulting net price / margin
-- * the cost at the moment of publishing (so margin stays comparable)
-- * the listing type and the description actually published
--
-- Also normalizes marketplace_questions.status to lowercase: ML returns
-- "UNANSWERED"/"ANSWERED" and the auto-answer/SLA queries filter lowercase.
-- ═══════════════════════════════════════════════════════════════════════════
-- ─── 1. marketplace_listings: match metadata + economics ────────────────────
ALTER TABLE marketplace_listings
ADD COLUMN IF NOT EXISTS listing_type_id VARCHAR(30),
ADD COLUMN IF NOT EXISTS inventory_match_source VARCHAR(40),
ADD COLUMN IF NOT EXISTS inventory_matched_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS meli_fee NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS meli_fee_pct NUMERIC(6,2),
ADD COLUMN IF NOT EXISTS meli_net_price NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS meli_fee_source VARCHAR(20),
ADD COLUMN IF NOT EXISTS cost_at_publish NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS margin_pct NUMERIC(8,2),
ADD COLUMN IF NOT EXISTS meli_description TEXT,
ADD COLUMN IF NOT EXISTS economics_updated_at TIMESTAMPTZ;
-- Fast lookup of the listings that still need an inventory match (backfill).
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_unmapped
ON marketplace_listings(channel)
WHERE inventory_id IS NULL;
-- ─── 2. marketplace_questions: status normalization ─────────────────────────
-- ML's API returns "UNANSWERED" / "ANSWERED"; the service compares lowercase.
-- Normalize historical rows so auto-answer and the SLA job see them.
UPDATE marketplace_questions
SET status = LOWER(status), updated_at = NOW()
WHERE status IS NOT NULL AND status <> LOWER(status);
-- Index for the SLA sweep: oldest unanswered questions first.
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status_date
ON marketplace_questions(status, question_date);
-- ─── 3. Allow several active ML listings per local product ──────────────────
-- The original v3.4 unique index assumed one ML listing per inventory item.
-- Real MeLi catalogs routinely publish several listings (different fitments /
-- titles) that share the same internal part number; the inventory backfill
-- must be able to link them all, and order conversion maps each order item by
-- external_item_id. Replace the uniqueness with a plain lookup index — the
-- publish path now upserts by (inventory_id, channel) explicitly.
DROP INDEX IF EXISTS idx_marketplace_listings_unique;
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inv_channel
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;