41 KiB
PLAN DE CORRECCIONES - ERP SUITE (erp-core)
Version: 1.2.0 Fecha: 2025-12-06 Autor: Requirements-Analyst Estado: ✅ FASE 1 Y FASE 2 COMPLETADAS
REGISTRO DE EJECUCION
| Correccion | Estado | Fecha | Archivos Modificados |
|---|---|---|---|
| CORR-001 | ✅ COMPLETADO | 2025-12-06 | orders.service.ts, sales.controller.ts, status.ts, DashboardPage.tsx |
| CORR-002 | ✅ COMPLETADO | 2025-12-06 | auth.controller.ts, auth.service.ts, users.controller.ts, users.service.ts |
| CORR-003 | ✅ COMPLETADO | 2025-12-06 | DDL originales (07-sales.sql, 06-purchase.sql, 04-financial.sql, 05-inventory.sql, 02-core.sql, 03-analytics.sql) |
| CORR-004 | ✅ COMPLETADO | 2025-12-06 | DDL original (02-core.sql - índices FK en partners) |
| CORR-005 | ✅ COMPLETADO | 2025-12-06 | taxes.service.ts, orders.service.ts, quotations.service.ts, invoices.service.ts |
| CORR-006 | ✅ COMPLETADO | 2025-12-06 | entities.types.ts |
| CORR-007 | ✅ COMPLETADO | 2025-12-06 | shared/services/base.service.ts (NUEVO) |
| CORR-008 | ✅ COMPLETADO | 2025-12-06 | 11-crm.sql, 12-hr.sql (RLS policies agregadas) |
NOTA IMPORTANTE - DIRECTIVA DE BASE DE DATOS
Por directiva del proyecto, NO se usan scripts de migración. Todas las correcciones de base de datos se aplicaron directamente a los archivos DDL originales para garantizar una carga limpia desde cero.
Archivos de migración eliminados:
migrations/20251206_001_add_tenant_id_to_lines.sqlmigrations/20251206_002_add_rls_to_lines.sqlmigrations/20251206_003_add_missing_fk_indexes.sql
Correcciones aplicadas a DDL originales:
- 07-sales.sql: tenant_id + RLS en sales_order_lines, quotation_lines, pricelist_items
- 06-purchase.sql: tenant_id + RLS en purchase_order_lines, rfq_lines, purchase_agreement_lines
- 04-financial.sql: tenant_id + RLS en journal_entry_lines, invoice_lines
- 05-inventory.sql: tenant_id + RLS en stock_quants, inventory_adjustment_lines
- 02-core.sql: índices FK en partners (currency_id, payment_term_id, pricelist_id)
- 03-analytics.sql: analytic_lines ya tenía tenant_id y RLS
- 11-crm.sql: RLS policies agregadas para todas las tablas
- 12-hr.sql: RLS policies agregadas para todas las tablas
RESUMEN EJECUTIVO
Este documento detalla el plan de correcciones para resolver las incoherencias criticas identificadas en el proyecto ERP Suite. Cada correccion ha sido analizada por impacto en componentes dependientes.
Total de correcciones: 6 criticas + 6 altas Tiempo estimado total: 40-50 horas Riesgo de ruptura: Controlado con orden de ejecucion
MATRIZ DE DEPENDENCIAS
┌─────────────────────────────────────────────┐
│ ORDEN DE EJECUCION │
└─────────────────────────────────────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CORR-001 │────▶│ CORR-002 │────▶│ CORR-005 │────▶│ CORR-006 │
│ Status Enum │ │ firstName │ │ Taxes Calc │ │ Types FE │
│ (DDL→BE→FE) │ │ Transformer │ │ (Depends │ │ (Depends │
└─────────────┘ └─────────────┘ │ BE ready) │ │ all ready) │
└─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ CORR-003 │
│ tenant_id │ ◀──── PARALELO con CORR-002
│ (DDL only) │
└─────────────┘
│
▼
┌─────────────┐
│ CORR-004 │
│ FK Indices │ ◀──── Depende de CORR-003
└─────────────┘
FASE 1: CORRECCIONES CRITICAS
CORR-001: Status Enum Sales Orders
Prioridad: CRITICA Tiempo estimado: 3-4 horas Riesgo de ruptura: MEDIO
Problema
- DDL define:
'draft' | 'sent' | 'sale' | 'done' | 'cancelled' - Backend usa:
'draft' | 'confirmed' | 'done' | 'cancelled'
Archivos a Modificar (5 archivos)
| # | Archivo | Linea | Cambio | Dependencias |
|---|---|---|---|---|
| 1 | database/ddl/07-sales.sql |
15-21 | VERIFICAR - Ya correcto | Ninguna |
| 2 | backend/src/modules/sales/orders.service.ts |
48 | Cambiar tipo union | CORR-001.3, CORR-001.4 |
| 3 | backend/src/modules/sales/orders.service.ts |
526 | 'confirmed' → 'sent' |
Ninguna |
| 4 | backend/src/modules/sales/orders.service.ts |
586 | Validacion createInvoice() |
CORR-005 |
| 5 | backend/src/modules/sales/sales.controller.ts |
204 | Zod enum schema | CORR-001.2 |
| 6 | frontend/src/shared/constants/status.ts |
3-26 | Labels y colores | CORR-001.2 |
| 7 | frontend/src/pages/dashboard/DashboardPage.tsx |
50-59 | Mock data | CORR-001.6 |
Orden de Ejecucion
PASO 1: Verificar DDL (07-sales.sql) - ya tiene valores correctos
│
PASO 2: Actualizar Backend (orders.service.ts)
│ - Linea 48: tipo interface
│ - Linea 526: transicion confirm() → 'sent'
│ - Linea 586: validacion createInvoice()
│
PASO 3: Actualizar Controller (sales.controller.ts)
│ - Linea 204: Zod enum
│
PASO 4: Actualizar Frontend constants (status.ts)
│ - DOCUMENT_STATUS
│ - DOCUMENT_STATUS_LABELS
│ - DOCUMENT_STATUS_COLORS
│
PASO 5: Actualizar Dashboard mock data (DashboardPage.tsx)
Cambios Detallados
Archivo: orders.service.ts:48
// ANTES
status: 'draft' | 'confirmed' | 'done' | 'cancelled';
// DESPUES
status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled';
Archivo: orders.service.ts:526
// ANTES (metodo confirm())
status = 'confirmed'
// DESPUES
status = 'sent'
Archivo: orders.service.ts:586
// ANTES (validacion createInvoice)
if (!['confirmed', 'done'].includes(order.status))
// DESPUES
if (!['sale', 'done'].includes(order.status))
Archivo: sales.controller.ts:204
// ANTES
status: z.enum(['draft', 'confirmed', 'done', 'cancelled']).optional()
// DESPUES
status: z.enum(['draft', 'sent', 'sale', 'done', 'cancelled']).optional()
Archivo: status.ts
// ANTES
export const DOCUMENT_STATUS = {
DRAFT: 'draft',
SENT: 'sent',
CONFIRMED: 'confirmed',
DONE: 'done',
CANCELLED: 'cancelled',
} as const;
// DESPUES
export const DOCUMENT_STATUS = {
DRAFT: 'draft',
SENT: 'sent',
SALE: 'sale',
DONE: 'done',
CANCELLED: 'cancelled',
} as const;
// Labels (actualizar)
export const DOCUMENT_STATUS_LABELS: Record<string, string> = {
[DOCUMENT_STATUS.DRAFT]: 'Borrador',
[DOCUMENT_STATUS.SENT]: 'Enviado',
[DOCUMENT_STATUS.SALE]: 'Venta', // NUEVO
[DOCUMENT_STATUS.DONE]: 'Completado',
[DOCUMENT_STATUS.CANCELLED]: 'Cancelado',
};
// Colors (actualizar)
export const DOCUMENT_STATUS_COLORS: Record<string, string> = {
[DOCUMENT_STATUS.DRAFT]: 'bg-gray-100 text-gray-800',
[DOCUMENT_STATUS.SENT]: 'bg-blue-100 text-blue-800',
[DOCUMENT_STATUS.SALE]: 'bg-cyan-100 text-cyan-800', // NUEVO
[DOCUMENT_STATUS.DONE]: 'bg-green-100 text-green-800',
[DOCUMENT_STATUS.CANCELLED]: 'bg-red-100 text-red-800',
};
NO Modificar (Otros modulos con 'confirmed')
| Archivo | Razon |
|---|---|
purchases.service.ts |
Enum separado para Purchase Orders |
quotations.service.ts |
Enum separado quotation_status |
pickings.service.ts |
Enum separado para movimientos |
adjustments.service.ts |
Enum separado para ajustes |
Validacion Post-Cambio
# 1. Verificar que el backend compila
npm run build
# 2. Verificar que no hay 'confirmed' en sales orders
grep -r "confirmed" backend/src/modules/sales/orders.service.ts
# Debe retornar 0 resultados
# 3. Test manual: crear orden, confirmar, verificar status = 'sent'
CORR-002: firstName/lastName vs full_name
Prioridad: CRITICA Tiempo estimado: 2-3 horas Riesgo de ruptura: BAJO (solucion transformador)
Problema
- Frontend envia:
firstName+lastName - Backend espera:
full_name - BD almacena:
full_name
Solucion Elegida: TRANSFORMADOR EN FRONTEND
Esta solucion no requiere cambios en BD ni Backend, minimizando riesgo.
Archivos a Crear (2 archivos nuevos)
| # | Archivo | Accion |
|---|---|---|
| 1 | frontend/src/services/api/transformers/user.transformer.ts |
CREAR |
| 2 | frontend/src/services/api/transformers/index.ts |
CREAR |
Archivos a Modificar (2 archivos)
| # | Archivo | Cambio |
|---|---|---|
| 3 | frontend/src/features/users/api/users.api.ts |
Aplicar transformador |
| 4 | frontend/src/services/api/auth.api.ts |
Aplicar transformador |
Orden de Ejecucion
PASO 1: Crear transformadores (user.transformer.ts)
│
PASO 2: Crear index de exportacion (transformers/index.ts)
│
PASO 3: Aplicar en users.api.ts
│ - create()
│ - update()
│ - getAll() response
│ - getById() response
│
PASO 4: Aplicar en auth.api.ts
│ - register()
│ - login() response
│ - getCurrentUser() response
Cambios Detallados
Archivo NUEVO: transformers/user.transformer.ts
// frontend/src/services/api/transformers/user.transformer.ts
import type { User, CreateUserDto, UpdateUserDto } from '@/features/users/types/user.types';
interface BackendUser {
id: string;
email: string;
full_name: string;
phone?: string;
avatar?: string;
role_id: string;
status: string;
tenant_id: string;
company_id: string;
last_login_at?: string;
created_at: string;
updated_at?: string;
}
interface BackendCreateUserDto {
email: string;
password: string;
full_name: string;
phone?: string;
role_id: string;
company_id?: string;
}
export const userTransformers = {
/**
* Transforma datos del frontend al formato del backend
* firstName + lastName -> full_name
*/
toBackend: (data: CreateUserDto | UpdateUserDto): BackendCreateUserDto => {
const { firstName, lastName, roleId, ...rest } = data as any;
return {
...rest,
full_name: `${firstName || ''} ${lastName || ''}`.trim(),
role_id: roleId,
};
},
/**
* Transforma datos del backend al formato del frontend
* full_name -> firstName + lastName
*/
fromBackend: (data: BackendUser): User => {
const nameParts = (data.full_name || '').split(' ');
const firstName = nameParts[0] || '';
const lastName = nameParts.slice(1).join(' ') || '';
return {
id: data.id,
email: data.email,
firstName,
lastName,
phone: data.phone,
avatar: data.avatar,
roleId: data.role_id,
status: data.status as any,
tenantId: data.tenant_id,
companyId: data.company_id,
lastLoginAt: data.last_login_at,
createdAt: data.created_at,
updatedAt: data.updated_at,
};
},
/**
* Transforma array de usuarios del backend
*/
fromBackendList: (data: BackendUser[]): User[] => {
return data.map(user => userTransformers.fromBackend(user));
},
};
Archivo NUEVO: transformers/index.ts
// frontend/src/services/api/transformers/index.ts
export { userTransformers } from './user.transformer';
Archivo: users.api.ts (modificar)
// Agregar import
import { userTransformers } from '@/services/api/transformers';
// Modificar metodos
export const usersApi = {
getAll: async (filters?: UserFilters): Promise<PaginatedResponse<User>> => {
const response = await api.get<PaginatedResponse<any>>(BASE_URL, { params: filters });
return {
...response.data,
data: userTransformers.fromBackendList(response.data.data),
};
},
getById: async (id: string): Promise<User> => {
const response = await api.get<any>(`${BASE_URL}/${id}`);
return userTransformers.fromBackend(response.data);
},
create: async (data: CreateUserDto): Promise<User> => {
const backendData = userTransformers.toBackend(data);
const response = await api.post<any>(BASE_URL, backendData);
return userTransformers.fromBackend(response.data);
},
update: async (id: string, data: UpdateUserDto): Promise<User> => {
const backendData = userTransformers.toBackend(data);
const response = await api.put<any>(`${BASE_URL}/${id}`, backendData);
return userTransformers.fromBackend(response.data);
},
// ... resto de metodos sin cambios
};
Archivo: auth.api.ts (modificar)
// Agregar import
import { userTransformers } from '@/services/api/transformers';
// Modificar metodos que devuelven User
export const authApi = {
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await api.post<any>(API_ENDPOINTS.AUTH.LOGIN, credentials);
return {
...response.data,
user: userTransformers.fromBackend(response.data.user),
};
},
register: async (data: RegisterData): Promise<AuthResponse> => {
const backendData = {
email: data.email,
password: data.password,
full_name: `${data.firstName} ${data.lastName}`.trim(),
};
const response = await api.post<any>(API_ENDPOINTS.AUTH.REGISTER, backendData);
return {
...response.data,
user: userTransformers.fromBackend(response.data.user),
};
},
getCurrentUser: async (): Promise<User> => {
const response = await api.get<any>(API_ENDPOINTS.AUTH.ME);
return userTransformers.fromBackend(response.data);
},
// ... resto sin cambios
};
Validacion Post-Cambio
# 1. Verificar que frontend compila
npm run build
# 2. Test manual:
# - Crear usuario con firstName="Juan" lastName="Perez Garcia"
# - Verificar en BD: full_name = "Juan Perez Garcia"
# - Verificar en UI: muestra firstName="Juan" lastName="Perez Garcia"
# 3. Test registro:
# - Registrar usuario nuevo
# - Verificar login funciona
# - Verificar nombre se muestra correctamente
CORR-003: Agregar tenant_id a Tablas de Lineas
Prioridad: CRITICA (Seguridad) Tiempo estimado: 4-6 horas Riesgo de ruptura: MEDIO (migracion de datos)
Problema
12 tablas de lineas de documentos no tienen tenant_id, comprometiendo aislamiento multi-tenant.
Tablas Afectadas
| # | Tabla | Tabla Padre | Complejidad |
|---|---|---|---|
| 1 | financial.journal_entry_lines |
journal_entries | Baja |
| 2 | financial.invoice_lines |
invoices | Baja |
| 3 | purchase.purchase_order_lines |
purchase_orders | Baja |
| 4 | sales.sales_order_lines |
sales_orders | Baja |
| 5 | sales.quotation_lines |
quotations | Baja |
| 6 | sales.pricelist_items |
pricelists | Media |
| 7 | inventory.stock_quants |
products + locations | ALTA |
| 8 | billing.invoice_lines |
billing.invoices | Baja |
| 9 | system.message_followers |
Polimorfica | ALTA |
| 10 | system.dashboard_widgets |
dashboards | Baja |
Orden de Ejecucion (4 fases)
FASE 1: Agregar columnas NULLABLE (sin downtime)
│
▼
FASE 2: Migrar datos (UPDATE con subquery)
│
▼
FASE 3: Cambiar a NOT NULL + FK + RLS
│
▼
FASE 4: Actualizar backend services
Script de Migracion DDL
Archivo: database/migrations/20251206_add_tenant_id_to_lines.sql
-- ============================================================
-- MIGRACION: Agregar tenant_id a tablas de lineas
-- Fecha: 2025-12-06
-- Autor: Requirements-Analyst
-- ============================================================
BEGIN;
-- ============================================================
-- FASE 1: Agregar columnas NULLABLE
-- ============================================================
-- 1.1 financial.journal_entry_lines
ALTER TABLE financial.journal_entry_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.2 financial.invoice_lines
ALTER TABLE financial.invoice_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.3 purchase.purchase_order_lines
ALTER TABLE purchase.purchase_order_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.4 sales.sales_order_lines
ALTER TABLE sales.sales_order_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.5 sales.quotation_lines
ALTER TABLE sales.quotation_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.6 sales.pricelist_items
ALTER TABLE sales.pricelist_items
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.7 inventory.stock_quants
ALTER TABLE inventory.stock_quants
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.8 billing.invoice_lines
ALTER TABLE billing.invoice_lines
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.9 system.message_followers
ALTER TABLE system.message_followers
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- 1.10 system.dashboard_widgets
ALTER TABLE system.dashboard_widgets
ADD COLUMN IF NOT EXISTS tenant_id UUID;
-- ============================================================
-- FASE 2: Migrar datos (heredar tenant_id de tabla padre)
-- ============================================================
-- 2.1 journal_entry_lines <- journal_entries
UPDATE financial.journal_entry_lines l
SET tenant_id = e.tenant_id
FROM financial.journal_entries e
WHERE l.entry_id = e.id AND l.tenant_id IS NULL;
-- 2.2 invoice_lines <- invoices
UPDATE financial.invoice_lines l
SET tenant_id = i.tenant_id
FROM financial.invoices i
WHERE l.invoice_id = i.id AND l.tenant_id IS NULL;
-- 2.3 purchase_order_lines <- purchase_orders
UPDATE purchase.purchase_order_lines l
SET tenant_id = o.tenant_id
FROM purchase.purchase_orders o
WHERE l.order_id = o.id AND l.tenant_id IS NULL;
-- 2.4 sales_order_lines <- sales_orders
UPDATE sales.sales_order_lines l
SET tenant_id = o.tenant_id
FROM sales.sales_orders o
WHERE l.order_id = o.id AND l.tenant_id IS NULL;
-- 2.5 quotation_lines <- quotations
UPDATE sales.quotation_lines l
SET tenant_id = q.tenant_id
FROM sales.quotations q
WHERE l.quotation_id = q.id AND l.tenant_id IS NULL;
-- 2.6 pricelist_items <- pricelists
UPDATE sales.pricelist_items l
SET tenant_id = p.tenant_id
FROM sales.pricelists p
WHERE l.pricelist_id = p.id AND l.tenant_id IS NULL;
-- 2.7 stock_quants <- products (usa product.tenant_id)
UPDATE inventory.stock_quants q
SET tenant_id = p.tenant_id
FROM inventory.products p
WHERE q.product_id = p.id AND q.tenant_id IS NULL;
-- 2.8 billing.invoice_lines <- billing.invoices
UPDATE billing.invoice_lines l
SET tenant_id = i.tenant_id
FROM billing.invoices i
WHERE l.invoice_id = i.id AND l.tenant_id IS NULL;
-- 2.9 message_followers: usar user.tenant_id o partner.tenant_id
UPDATE system.message_followers f
SET tenant_id = COALESCE(
(SELECT u.tenant_id FROM auth.users u WHERE u.id = f.user_id),
(SELECT p.tenant_id FROM core.partners p WHERE p.id = f.partner_id)
)
WHERE f.tenant_id IS NULL;
-- 2.10 dashboard_widgets <- dashboards
UPDATE system.dashboard_widgets w
SET tenant_id = d.tenant_id
FROM system.dashboards d
WHERE w.dashboard_id = d.id AND w.tenant_id IS NULL;
-- ============================================================
-- FASE 3: Cambiar a NOT NULL + Foreign Keys
-- ============================================================
-- 3.1 Constraints NOT NULL
ALTER TABLE financial.journal_entry_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE financial.invoice_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE purchase.purchase_order_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE sales.sales_order_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE sales.quotation_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE sales.pricelist_items
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE inventory.stock_quants
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE billing.invoice_lines
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE system.message_followers
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE system.dashboard_widgets
ALTER COLUMN tenant_id SET NOT NULL;
-- 3.2 Foreign Keys a auth.tenants
ALTER TABLE financial.journal_entry_lines
ADD CONSTRAINT fk_journal_entry_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE financial.invoice_lines
ADD CONSTRAINT fk_invoice_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE purchase.purchase_order_lines
ADD CONSTRAINT fk_po_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE sales.sales_order_lines
ADD CONSTRAINT fk_so_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE sales.quotation_lines
ADD CONSTRAINT fk_quotation_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE sales.pricelist_items
ADD CONSTRAINT fk_pricelist_items_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE inventory.stock_quants
ADD CONSTRAINT fk_stock_quants_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE billing.invoice_lines
ADD CONSTRAINT fk_billing_invoice_lines_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE system.message_followers
ADD CONSTRAINT fk_message_followers_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
ALTER TABLE system.dashboard_widgets
ADD CONSTRAINT fk_dashboard_widgets_tenant
FOREIGN KEY (tenant_id) REFERENCES auth.tenants(id);
-- ============================================================
-- FASE 4: Indices para performance
-- ============================================================
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_journal_entry_lines_tenant ON financial.journal_entry_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_invoice_lines_tenant ON financial.invoice_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_po_lines_tenant ON purchase.purchase_order_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_so_lines_tenant ON sales.sales_order_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_quotation_lines_tenant ON sales.quotation_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_pricelist_items_tenant ON sales.pricelist_items(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_stock_quants_tenant ON inventory.stock_quants(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_billing_invoice_lines_tenant ON billing.invoice_lines(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_message_followers_tenant ON system.message_followers(tenant_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_dashboard_widgets_tenant ON system.dashboard_widgets(tenant_id);
-- ============================================================
-- FASE 5: RLS Policies
-- ============================================================
-- Habilitar RLS
ALTER TABLE financial.journal_entry_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE financial.invoice_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE purchase.purchase_order_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.sales_order_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.quotation_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE sales.pricelist_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory.stock_quants ENABLE ROW LEVEL SECURITY;
ALTER TABLE billing.invoice_lines ENABLE ROW LEVEL SECURITY;
ALTER TABLE system.message_followers ENABLE ROW LEVEL SECURITY;
ALTER TABLE system.dashboard_widgets ENABLE ROW LEVEL SECURITY;
-- Policies de aislamiento
CREATE POLICY tenant_isolation_journal_entry_lines
ON financial.journal_entry_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_invoice_lines
ON financial.invoice_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_po_lines
ON purchase.purchase_order_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_so_lines
ON sales.sales_order_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_quotation_lines
ON sales.quotation_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_pricelist_items
ON sales.pricelist_items
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_stock_quants
ON inventory.stock_quants
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_billing_invoice_lines
ON billing.invoice_lines
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_message_followers
ON system.message_followers
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_dashboard_widgets
ON system.dashboard_widgets
USING (tenant_id = auth.get_current_tenant_id());
COMMIT;
-- ============================================================
-- VERIFICACION
-- ============================================================
-- Verificar que todas las columnas existen
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE column_name = 'tenant_id'
AND table_schema IN ('financial', 'purchase', 'sales', 'inventory', 'billing', 'system')
ORDER BY table_schema, table_name;
-- Verificar que no hay NULLs
SELECT 'financial.journal_entry_lines' as tabla, COUNT(*) as nulls
FROM financial.journal_entry_lines WHERE tenant_id IS NULL
UNION ALL
SELECT 'financial.invoice_lines', COUNT(*)
FROM financial.invoice_lines WHERE tenant_id IS NULL
UNION ALL
SELECT 'sales.sales_order_lines', COUNT(*)
FROM sales.sales_order_lines WHERE tenant_id IS NULL;
-- Debe retornar 0 para todas
Cambios en Backend Services
Cada servicio que inserta en estas tablas debe agregar tenant_id:
Ejemplo: orders.service.ts - addLine()
// ANTES (linea ~400)
INSERT INTO sales.sales_order_lines
(order_id, product_id, description, ...)
VALUES ($1, $2, $3, ...)
// DESPUES
INSERT INTO sales.sales_order_lines
(order_id, product_id, description, tenant_id, ...)
VALUES ($1, $2, $3, $4, ...)
// Agregar tenantId como parametro
Validacion Post-Cambio
-- 1. Verificar columnas creadas
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name = 'tenant_id'
AND table_schema IN ('financial', 'sales', 'purchase', 'inventory');
-- 2. Verificar no hay NULLs
SELECT COUNT(*) FROM sales.sales_order_lines WHERE tenant_id IS NULL;
-- Debe ser 0
-- 3. Verificar RLS funciona
SET app.current_tenant_id = 'tenant-uuid-1';
SELECT COUNT(*) FROM sales.sales_order_lines;
-- Solo debe mostrar datos del tenant-1
CORR-004: Crear Indices FK Faltantes
Prioridad: ALTA (Performance) Tiempo estimado: 1 hora Riesgo de ruptura: NINGUNO
Foreign Keys sin Indice
| # | Tabla | Columna FK | Indice a crear |
|---|---|---|---|
| 1 | core.partners |
currency_id |
idx_partners_currency_id |
| 2 | core.partners |
payment_term_id |
idx_partners_payment_term_id |
| 3 | core.partners |
pricelist_id |
idx_partners_pricelist_id |
| 4 | financial.invoice_lines |
product_id |
idx_invoice_lines_product_id |
Script DDL
Archivo: database/migrations/20251206_add_missing_fk_indexes.sql
-- Indices para FK sin indice (CONCURRENTLY para no bloquear)
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_partners_currency_id ON core.partners(currency_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_partners_payment_term_id ON core.partners(payment_term_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_partners_pricelist_id ON core.partners(pricelist_id);
CREATE INDEX CONCURRENTLY IF NOT EXISTS
idx_invoice_lines_product_id ON financial.invoice_lines(product_id);
-- Verificacion
SELECT indexname, tablename
FROM pg_indexes
WHERE indexname LIKE 'idx_partners_%' OR indexname LIKE 'idx_invoice_lines_%';
CORR-005: Implementar Calculo de Impuestos
Prioridad: CRITICA Tiempo estimado: 8-10 horas Riesgo de ruptura: BAJO (nueva funcionalidad)
Problema
Todos los documentos tienen amount_tax = 0 debido a TODOs sin implementar.
Archivos a Modificar
| # | Archivo | Linea | Cambio |
|---|---|---|---|
| 1 | backend/src/modules/financial/taxes.service.ts |
N/A | Agregar calculateTaxes() |
| 2 | backend/src/modules/sales/orders.service.ts |
386 | Usar calculateTaxes() |
| 3 | backend/src/modules/sales/orders.service.ts |
462 | Usar en updateLine() |
| 4 | backend/src/modules/sales/quotations.service.ts |
337 | Usar calculateTaxes() |
| 5 | backend/src/modules/sales/quotations.service.ts |
408 | Usar en updateLine() |
| 6 | backend/src/modules/financial/invoices.service.ts |
326 | Usar calculateTaxes() |
| 7 | backend/src/modules/financial/invoices.service.ts |
398 | Usar en updateLine() |
Implementacion
Archivo: taxes.service.ts (agregar metodos)
// Agregar al final de TaxesService
interface TaxCalculationResult {
amountUntaxed: number;
amountTax: number;
amountTotal: number;
taxBreakdown: {
taxId: string;
taxName: string;
taxRate: number;
base: number;
taxAmount: number;
}[];
}
interface LineData {
quantity: number;
priceUnit: number;
discount: number;
taxIds: string[];
}
/**
* Calcula impuestos para una linea de documento
*/
async calculateTaxes(
lineData: LineData,
tenantId: string,
transactionType: 'sales' | 'purchase' = 'sales'
): Promise<TaxCalculationResult> {
// 1. Validar inputs
if (lineData.quantity <= 0) {
throw new ValidationError('Quantity must be greater than 0');
}
if (lineData.priceUnit < 0) {
throw new ValidationError('Price must be >= 0');
}
if (lineData.discount < 0 || lineData.discount > 100) {
throw new ValidationError('Discount must be between 0 and 100');
}
// 2. Calcular base imponible
const subtotal = lineData.quantity * lineData.priceUnit;
const discountAmount = subtotal * (lineData.discount / 100);
const amountUntaxed = this.roundToDecimals(subtotal - discountAmount, 2);
// 3. Si no hay impuestos, retornar solo base
if (!lineData.taxIds || lineData.taxIds.length === 0) {
return {
amountUntaxed,
amountTax: 0,
amountTotal: amountUntaxed,
taxBreakdown: [],
};
}
// 4. Obtener impuestos de la BD
const taxes = await this.getTaxesByIds(lineData.taxIds, tenantId);
// 5. Validar tipo de impuesto
const validTypes = transactionType === 'sales'
? ['sales', 'all']
: ['purchase', 'all'];
for (const tax of taxes) {
if (!validTypes.includes(tax.tax_type)) {
throw new ValidationError(
`Tax ${tax.code} is not applicable to ${transactionType} transactions`
);
}
}
// 6. Calcular impuestos
const taxBreakdown = taxes
.filter(tax => tax.active)
.map(tax => {
const taxAmount = this.roundToDecimals(amountUntaxed * tax.rate, 2);
return {
taxId: tax.id,
taxName: tax.name,
taxRate: tax.rate * 100, // Convertir a porcentaje
base: amountUntaxed,
taxAmount,
};
});
// 7. Sumar impuestos
const amountTax = this.roundToDecimals(
taxBreakdown.reduce((sum, t) => sum + t.taxAmount, 0),
2
);
// 8. Total
const amountTotal = this.roundToDecimals(amountUntaxed + amountTax, 2);
return {
amountUntaxed,
amountTax,
amountTotal,
taxBreakdown,
};
}
/**
* Obtiene multiples impuestos por IDs
*/
private async getTaxesByIds(taxIds: string[], tenantId: string): Promise<Tax[]> {
if (taxIds.length === 0) return [];
const result = await query<Tax>(
`SELECT * FROM financial.taxes
WHERE id = ANY($1) AND tenant_id = $2`,
[taxIds, tenantId]
);
return result.rows;
}
/**
* Redondea a N decimales
*/
private roundToDecimals(value: number, decimals: number): number {
const factor = Math.pow(10, decimals);
return Math.round(value * factor) / factor;
}
Archivo: orders.service.ts:386 (modificar)
// ANTES
const subtotal = dto.quantity * dto.price_unit;
const discountAmount = subtotal * (dto.discount || 0) / 100;
const amountUntaxed = subtotal - discountAmount;
// TODO: Calculate taxes properly based on tax_ids
const amountTax = 0;
const amountTotal = amountUntaxed + amountTax;
// DESPUES
const taxResult = await this.taxesService.calculateTaxes(
{
quantity: dto.quantity,
priceUnit: dto.price_unit,
discount: dto.discount || 0,
taxIds: dto.tax_ids || [],
},
tenantId,
'sales'
);
const amountUntaxed = taxResult.amountUntaxed;
const amountTax = taxResult.amountTax;
const amountTotal = taxResult.amountTotal;
Dependencias
- Requiere que
TaxesServiceeste inyectado enOrdersService,QuotationsService,InvoicesService - Agregar import y constructor injection
CORR-006: Consolidar Tipos Frontend
Prioridad: ALTA Tiempo estimado: 2-3 horas Riesgo de ruptura: BAJO
Problema
Hay 2 definiciones conflictivas de User:
shared/types/entities.types.ts- defineroles: string[]features/users/types/user.types.ts- defineroleId: string
Solucion
Consolidar en features/*/types/ como fuente de verdad y hacer que shared/types/ solo re-exporte.
Archivos a Modificar
| # | Archivo | Cambio |
|---|---|---|
| 1 | shared/types/entities.types.ts |
Eliminar User, Partner, Company duplicados |
| 2 | shared/types/entities.types.ts |
Re-exportar desde features/ |
| 3 | shared/stores/useAuthStore.ts |
Actualizar import |
Cambios Detallados
Archivo: shared/types/entities.types.ts
// ANTES (conflicto)
export interface User extends BaseEntity {
email: string;
firstName: string;
lastName: string;
isActive: boolean;
roles: string[]; // CONFLICTO con roleId
tenantId: string;
}
// DESPUES (re-exportar)
// Mantener BaseEntity local
export interface BaseEntity {
id: string;
createdAt: string;
updatedAt?: string;
}
// Re-exportar tipos de features
export type { User, CreateUserDto, UpdateUserDto } from '@/features/users/types/user.types';
export type { Partner, CreatePartnerDto, UpdatePartnerDto } from '@/features/partners/types/partner.types';
export type { Company, CreateCompanyDto, UpdateCompanyDto } from '@/features/companies/types/company.types';
// Eliminar interfaces duplicadas Product, etc.
FASE 2: CORRECCIONES ALTAS
CORR-007: Crear BaseService Reutilizable
Prioridad: ALTA Tiempo estimado: 4-6 horas
Problema
~5,000 lineas de codigo duplicado en metodos findAll(), update() entre 28+ servicios.
Solucion
Crear clase abstracta BaseService<T> con logica comun.
Archivo: backend/src/shared/services/base.service.ts
export abstract class BaseService<T, CreateDto, UpdateDto> {
protected abstract tableName: string;
protected abstract selectFields: string;
async findAll(
tenantId: string,
filters: PaginationFilters = {}
): Promise<PaginatedResult<T>> {
const { page = 1, limit = 20, ...customFilters } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
// Aplicar filtros custom
for (const [key, value] of Object.entries(customFilters)) {
if (value !== undefined && value !== null) {
whereClause += ` AND ${key} = $${paramIndex++}`;
params.push(value);
}
}
// Count total
const countResult = await queryOne<{ count: number }>(
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`,
params
);
// Get data
const dataResult = await query<T>(
`SELECT ${this.selectFields}
FROM ${this.tableName}
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
[...params, limit, offset]
);
return {
data: dataResult.rows,
total: countResult?.count || 0,
page,
limit,
totalPages: Math.ceil((countResult?.count || 0) / limit),
};
}
// ... otros metodos comunes
}
CORR-008: Implementar RLS en CRM y HR
Prioridad: ALTA Tiempo estimado: 2-3 horas
Problema
Schemas crm y hr no tienen RLS policies.
Script DDL
-- CRM
ALTER TABLE crm.leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE crm.opportunities ENABLE ROW LEVEL SECURITY;
ALTER TABLE crm.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_leads ON crm.leads
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_opportunities ON crm.opportunities
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_crm_activities ON crm.activities
USING (tenant_id = auth.get_current_tenant_id());
-- HR
ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.departments ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.contracts ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.leaves ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_employees ON hr.employees
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_departments ON hr.departments
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_contracts ON hr.contracts
USING (tenant_id = auth.get_current_tenant_id());
CREATE POLICY tenant_isolation_leaves ON hr.leaves
USING (tenant_id = auth.get_current_tenant_id());
CRONOGRAMA DE EJECUCION
SEMANA 1
├── Dia 1-2: CORR-001 (Status Enum) - 4h
├── Dia 2-3: CORR-002 (firstName Transformer) - 3h
├── Dia 3-4: CORR-004 (FK Indices) - 1h
└── Dia 4-5: CORR-006 (Types Frontend) - 3h
SEMANA 2
├── Dia 1-3: CORR-003 (tenant_id Migration) - 6h
├── Dia 3-5: CORR-005 (Taxes Calculation) - 10h
└── Buffer para testing - 4h
SEMANA 3
├── Dia 1-2: CORR-007 (BaseService) - 6h
├── Dia 3: CORR-008 (RLS CRM/HR) - 3h
└── Dia 4-5: Testing integral + Documentacion
ROLLBACK PLAN
CORR-001: Revertir Status Enum
# Revertir cambios en Git
git checkout HEAD~1 -- backend/src/modules/sales/orders.service.ts
git checkout HEAD~1 -- backend/src/modules/sales/sales.controller.ts
git checkout HEAD~1 -- frontend/src/shared/constants/status.ts
CORR-003: Revertir tenant_id
-- ROLLBACK (solo si es necesario)
ALTER TABLE financial.journal_entry_lines DROP COLUMN tenant_id;
ALTER TABLE financial.invoice_lines DROP COLUMN tenant_id;
-- ... etc para cada tabla
CORR-005: Revertir Taxes
// Revertir a:
const amountTax = 0; // TODO: Calculate taxes
CHECKLIST DE VALIDACION FINAL
- CORR-001: Orders pueden transicionar draft → sent → sale → done
- CORR-001: Frontend muestra labels correctos
- CORR-002: Crear usuario funciona con firstName/lastName
- CORR-002: Login retorna firstName/lastName parseados
- CORR-003: Todas las tablas tienen tenant_id NOT NULL
- CORR-003: RLS policies funcionan (test cross-tenant)
- CORR-004: Indices creados y usados (EXPLAIN ANALYZE)
- CORR-005: amount_tax calculado correctamente
- CORR-005: Multiples impuestos suman correctamente
- CORR-006: No hay errores de tipos en frontend
- CORR-007: BaseService reduce duplicacion
- CORR-008: CRM y HR aislados por tenant
Documento generado por Requirements-Analyst Fecha: 2025-12-06 Pendiente: Aprobacion del equipo antes de ejecutar