diff --git a/projects/erp-suite/apps/erp-core/backend/src/app.ts b/projects/erp-suite/apps/erp-core/backend/src/app.ts index 30c4a7c..cb4cdb7 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/app.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/app.ts @@ -7,6 +7,7 @@ import { config } from './config/index.js'; import { logger } from './shared/utils/logger.js'; import { AppError, ApiResponse } from './shared/types/index.js'; import authRoutes from './modules/auth/auth.routes.js'; +import apiKeysRoutes from './modules/auth/apiKeys.routes.js'; import usersRoutes from './modules/users/users.routes.js'; import companiesRoutes from './modules/companies/companies.routes.js'; import coreRoutes from './modules/core/core.routes.js'; @@ -19,6 +20,7 @@ import projectsRoutes from './modules/projects/projects.routes.js'; import systemRoutes from './modules/system/system.routes.js'; import crmRoutes from './modules/crm/crm.routes.js'; import hrRoutes from './modules/hr/hr.routes.js'; +import reportsRoutes from './modules/reports/reports.routes.js'; const app: Application = express(); @@ -48,6 +50,7 @@ app.get('/health', (_req: Request, res: Response) => { // API routes const apiPrefix = config.apiPrefix; app.use(`${apiPrefix}/auth`, authRoutes); +app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes); app.use(`${apiPrefix}/users`, usersRoutes); app.use(`${apiPrefix}/companies`, companiesRoutes); app.use(`${apiPrefix}/core`, coreRoutes); @@ -60,6 +63,7 @@ app.use(`${apiPrefix}/projects`, projectsRoutes); app.use(`${apiPrefix}/system`, systemRoutes); app.use(`${apiPrefix}/crm`, crmRoutes); app.use(`${apiPrefix}/hr`, hrRoutes); +app.use(`${apiPrefix}/reports`, reportsRoutes); // 404 handler app.use((_req: Request, res: Response) => { diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.controller.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.controller.ts new file mode 100644 index 0000000..bb6cb71 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.controller.ts @@ -0,0 +1,331 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js'; +import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const createApiKeySchema = z.object({ + name: z.string().min(1, 'Nombre requerido').max(255), + scope: z.string().max(100).optional(), + allowed_ips: z.array(z.string().ip()).optional(), + expiration_days: z.number().int().positive().max(365).optional(), +}); + +const updateApiKeySchema = z.object({ + name: z.string().min(1).max(255).optional(), + scope: z.string().max(100).nullable().optional(), + allowed_ips: z.array(z.string().ip()).nullable().optional(), + expiration_date: z.string().datetime().nullable().optional(), + is_active: z.boolean().optional(), +}); + +const listApiKeysSchema = z.object({ + user_id: z.string().uuid().optional(), + is_active: z.enum(['true', 'false']).optional(), + scope: z.string().optional(), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ApiKeysController { + /** + * Create a new API key + * POST /api/auth/api-keys + */ + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createApiKeySchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const dto: CreateApiKeyDto = { + ...validation.data, + user_id: req.user!.userId, + tenant_id: req.user!.tenantId, + }; + + const result = await apiKeysService.create(dto); + + const response: ApiResponse = { + success: true, + data: result, + message: 'API key creada exitosamente. Guarde la clave, no podrá verla de nuevo.', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * List API keys for the current user + * GET /api/auth/api-keys + */ + async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = listApiKeysSchema.safeParse(req.query); + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const filters: ApiKeyFilters = { + tenant_id: req.user!.tenantId, + // By default, only show user's own keys unless admin + user_id: validation.data.user_id || req.user!.userId, + }; + + // Admins can view all keys in tenant + if (validation.data.user_id && req.user!.roles.includes('admin')) { + filters.user_id = validation.data.user_id; + } + + if (validation.data.is_active !== undefined) { + filters.is_active = validation.data.is_active === 'true'; + } + + if (validation.data.scope) { + filters.scope = validation.data.scope; + } + + const apiKeys = await apiKeysService.findAll(filters); + + const response: ApiResponse = { + success: true, + data: apiKeys, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get a specific API key + * GET /api/auth/api-keys/:id + */ + async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + const apiKey = await apiKeysService.findById(id, req.user!.tenantId); + + if (!apiKey) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + // Check ownership (unless admin) + if (apiKey.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para ver esta API key', + }; + res.status(403).json(response); + return; + } + + const response: ApiResponse = { + success: true, + data: apiKey, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Update an API key + * PATCH /api/auth/api-keys/:id + */ + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + const validation = updateApiKeySchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para modificar esta API key', + }; + res.status(403).json(response); + return; + } + + const dto: UpdateApiKeyDto = { + ...validation.data, + expiration_date: validation.data.expiration_date + ? new Date(validation.data.expiration_date) + : validation.data.expiration_date === null + ? null + : undefined, + }; + + const updated = await apiKeysService.update(id, req.user!.tenantId, dto); + + const response: ApiResponse = { + success: true, + data: updated, + message: 'API key actualizada', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Revoke an API key (soft delete) + * POST /api/auth/api-keys/:id/revoke + */ + async revoke(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para revocar esta API key', + }; + res.status(403).json(response); + return; + } + + await apiKeysService.revoke(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + message: 'API key revocada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Delete an API key permanently + * DELETE /api/auth/api-keys/:id + */ + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para eliminar esta API key', + }; + res.status(403).json(response); + return; + } + + await apiKeysService.delete(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + message: 'API key eliminada permanentemente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Regenerate an API key (invalidates old key, creates new) + * POST /api/auth/api-keys/:id/regenerate + */ + async regenerate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para regenerar esta API key', + }; + res.status(403).json(response); + return; + } + + const result = await apiKeysService.regenerate(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + data: result, + message: 'API key regenerada. Guarde la nueva clave, no podrá verla de nuevo.', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const apiKeysController = new ApiKeysController(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.routes.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.routes.ts new file mode 100644 index 0000000..b6ea65d --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.routes.ts @@ -0,0 +1,56 @@ +import { Router } from 'express'; +import { apiKeysController } from './apiKeys.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ============================================================================ +// API KEY MANAGEMENT ROUTES +// ============================================================================ + +/** + * Create a new API key + * POST /api/auth/api-keys + */ +router.post('/', (req, res, next) => apiKeysController.create(req, res, next)); + +/** + * List API keys (user's own, or all for admins) + * GET /api/auth/api-keys + */ +router.get('/', (req, res, next) => apiKeysController.list(req, res, next)); + +/** + * Get a specific API key + * GET /api/auth/api-keys/:id + */ +router.get('/:id', (req, res, next) => apiKeysController.getById(req, res, next)); + +/** + * Update an API key + * PATCH /api/auth/api-keys/:id + */ +router.patch('/:id', (req, res, next) => apiKeysController.update(req, res, next)); + +/** + * Revoke an API key (soft delete) + * POST /api/auth/api-keys/:id/revoke + */ +router.post('/:id/revoke', (req, res, next) => apiKeysController.revoke(req, res, next)); + +/** + * Delete an API key permanently + * DELETE /api/auth/api-keys/:id + */ +router.delete('/:id', (req, res, next) => apiKeysController.delete(req, res, next)); + +/** + * Regenerate an API key + * POST /api/auth/api-keys/:id/regenerate + */ +router.post('/:id/regenerate', (req, res, next) => apiKeysController.regenerate(req, res, next)); + +export default router; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.service.ts new file mode 100644 index 0000000..784640a --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/apiKeys.service.ts @@ -0,0 +1,491 @@ +import crypto from 'crypto'; +import { query, queryOne } from '../../config/database.js'; +import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ApiKey { + id: string; + user_id: string; + tenant_id: string; + name: string; + key_index: string; + key_hash: string; + scope: string | null; + allowed_ips: string[] | null; + expiration_date: Date | null; + last_used_at: Date | null; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateApiKeyDto { + user_id: string; + tenant_id: string; + name: string; + scope?: string; + allowed_ips?: string[]; + expiration_days?: number; +} + +export interface UpdateApiKeyDto { + name?: string; + scope?: string; + allowed_ips?: string[]; + expiration_date?: Date | null; + is_active?: boolean; +} + +export interface ApiKeyWithPlainKey { + apiKey: Omit; + plainKey: string; +} + +export interface ApiKeyValidationResult { + valid: boolean; + apiKey?: ApiKey; + user?: { + id: string; + tenant_id: string; + email: string; + roles: string[]; + }; + error?: string; +} + +export interface ApiKeyFilters { + user_id?: string; + tenant_id?: string; + is_active?: boolean; + scope?: string; +} + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const API_KEY_PREFIX = 'mgn_'; +const KEY_LENGTH = 32; // 32 bytes = 256 bits +const HASH_ITERATIONS = 100000; +const HASH_KEYLEN = 64; +const HASH_DIGEST = 'sha512'; + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ApiKeysService { + /** + * Generate a cryptographically secure API key + */ + private generatePlainKey(): string { + const randomBytes = crypto.randomBytes(KEY_LENGTH); + const key = randomBytes.toString('base64url'); + return `${API_KEY_PREFIX}${key}`; + } + + /** + * Extract the key index (first 16 chars after prefix) for lookup + */ + private getKeyIndex(plainKey: string): string { + const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, ''); + return keyWithoutPrefix.substring(0, 16); + } + + /** + * Hash the API key using PBKDF2 + */ + private async hashKey(plainKey: string): Promise { + const salt = crypto.randomBytes(16).toString('hex'); + + return new Promise((resolve, reject) => { + crypto.pbkdf2( + plainKey, + salt, + HASH_ITERATIONS, + HASH_KEYLEN, + HASH_DIGEST, + (err, derivedKey) => { + if (err) reject(err); + resolve(`${salt}:${derivedKey.toString('hex')}`); + } + ); + }); + } + + /** + * Verify a plain key against a stored hash + */ + private async verifyKey(plainKey: string, storedHash: string): Promise { + const [salt, hash] = storedHash.split(':'); + + return new Promise((resolve, reject) => { + crypto.pbkdf2( + plainKey, + salt, + HASH_ITERATIONS, + HASH_KEYLEN, + HASH_DIGEST, + (err, derivedKey) => { + if (err) reject(err); + resolve(derivedKey.toString('hex') === hash); + } + ); + }); + } + + /** + * Create a new API key + * Returns the plain key only once - it cannot be retrieved later + */ + async create(dto: CreateApiKeyDto): Promise { + // Validate user exists + const user = await queryOne<{ id: string }>( + 'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2', + [dto.user_id, dto.tenant_id] + ); + + if (!user) { + throw new ValidationError('Usuario no encontrado'); + } + + // Check for duplicate name + const existing = await queryOne<{ id: string }>( + 'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2', + [dto.user_id, dto.name] + ); + + if (existing) { + throw new ValidationError('Ya existe una API key con ese nombre'); + } + + // Generate key + const plainKey = this.generatePlainKey(); + const keyIndex = this.getKeyIndex(plainKey); + const keyHash = await this.hashKey(plainKey); + + // Calculate expiration date + let expirationDate: Date | null = null; + if (dto.expiration_days) { + expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + dto.expiration_days); + } + + // Insert API key + const apiKey = await queryOne( + `INSERT INTO auth.api_keys ( + user_id, tenant_id, name, key_index, key_hash, + scope, allowed_ips, expiration_date, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, is_active, created_at, updated_at`, + [ + dto.user_id, + dto.tenant_id, + dto.name, + keyIndex, + keyHash, + dto.scope || null, + dto.allowed_ips || null, + expirationDate, + ] + ); + + if (!apiKey) { + throw new Error('Error al crear API key'); + } + + logger.info('API key created', { + apiKeyId: apiKey.id, + userId: dto.user_id, + name: dto.name + }); + + return { + apiKey, + plainKey, // Only returned once! + }; + } + + /** + * Find all API keys for a user/tenant + */ + async findAll(filters: ApiKeyFilters): Promise[]> { + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (filters.user_id) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.user_id); + } + + if (filters.tenant_id) { + conditions.push(`tenant_id = $${paramIndex++}`); + params.push(filters.tenant_id); + } + + if (filters.is_active !== undefined) { + conditions.push(`is_active = $${paramIndex++}`); + params.push(filters.is_active); + } + + if (filters.scope) { + conditions.push(`scope = $${paramIndex++}`); + params.push(filters.scope); + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + const apiKeys = await query( + `SELECT id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at + FROM auth.api_keys + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + return apiKeys; + } + + /** + * Find a specific API key by ID + */ + async findById(id: string, tenantId: string): Promise | null> { + const apiKey = await queryOne( + `SELECT id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at + FROM auth.api_keys + WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + return apiKey; + } + + /** + * Update an API key + */ + async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise> { + const existing = await this.findById(id, tenantId); + if (!existing) { + throw new NotFoundError('API key no encontrada'); + } + + const updates: string[] = ['updated_at = NOW()']; + const params: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + params.push(dto.name); + } + + if (dto.scope !== undefined) { + updates.push(`scope = $${paramIndex++}`); + params.push(dto.scope); + } + + if (dto.allowed_ips !== undefined) { + updates.push(`allowed_ips = $${paramIndex++}`); + params.push(dto.allowed_ips); + } + + if (dto.expiration_date !== undefined) { + updates.push(`expiration_date = $${paramIndex++}`); + params.push(dto.expiration_date); + } + + if (dto.is_active !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + params.push(dto.is_active); + } + + params.push(id); + params.push(tenantId); + + const updated = await queryOne( + `UPDATE auth.api_keys + SET ${updates.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at`, + params + ); + + if (!updated) { + throw new Error('Error al actualizar API key'); + } + + logger.info('API key updated', { apiKeyId: id }); + + return updated; + } + + /** + * Revoke (soft delete) an API key + */ + async revoke(id: string, tenantId: string): Promise { + const result = await query( + `UPDATE auth.api_keys + SET is_active = false, updated_at = NOW() + WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!result) { + throw new NotFoundError('API key no encontrada'); + } + + logger.info('API key revoked', { apiKeyId: id }); + } + + /** + * Delete an API key permanently + */ + async delete(id: string, tenantId: string): Promise { + const result = await query( + 'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', + [id, tenantId] + ); + + logger.info('API key deleted', { apiKeyId: id }); + } + + /** + * Validate an API key and return the associated user info + * This is the main method used by the authentication middleware + */ + async validate(plainKey: string, clientIp?: string): Promise { + // Check prefix + if (!plainKey.startsWith(API_KEY_PREFIX)) { + return { valid: false, error: 'Formato de API key inválido' }; + } + + // Extract key index for lookup + const keyIndex = this.getKeyIndex(plainKey); + + // Find API key by index + const apiKey = await queryOne( + `SELECT * FROM auth.api_keys + WHERE key_index = $1 AND is_active = true`, + [keyIndex] + ); + + if (!apiKey) { + return { valid: false, error: 'API key no encontrada o inactiva' }; + } + + // Verify hash + const isValid = await this.verifyKey(plainKey, apiKey.key_hash); + if (!isValid) { + return { valid: false, error: 'API key inválida' }; + } + + // Check expiration + if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) { + return { valid: false, error: 'API key expirada' }; + } + + // Check IP whitelist + if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) { + if (!apiKey.allowed_ips.includes(clientIp)) { + logger.warn('API key IP not allowed', { + apiKeyId: apiKey.id, + clientIp, + allowedIps: apiKey.allowed_ips + }); + return { valid: false, error: 'IP no autorizada' }; + } + } + + // Get user info with roles + const user = await queryOne<{ + id: string; + tenant_id: string; + email: string; + role_codes: string[]; + }>( + `SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes + FROM auth.users u + LEFT JOIN auth.user_roles ur ON u.id = ur.user_id + LEFT JOIN auth.roles r ON ur.role_id = r.id + WHERE u.id = $1 AND u.status = 'active' + GROUP BY u.id`, + [apiKey.user_id] + ); + + if (!user) { + return { valid: false, error: 'Usuario asociado no encontrado o inactivo' }; + } + + // Update last used timestamp (async, don't wait) + query( + 'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1', + [apiKey.id] + ).catch(err => logger.error('Error updating last_used_at', { error: err })); + + return { + valid: true, + apiKey, + user: { + id: user.id, + tenant_id: user.tenant_id, + email: user.email, + roles: user.role_codes?.filter(Boolean) || [], + }, + }; + } + + /** + * Regenerate an API key (creates new key, invalidates old) + */ + async regenerate(id: string, tenantId: string): Promise { + const existing = await queryOne( + 'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', + [id, tenantId] + ); + + if (!existing) { + throw new NotFoundError('API key no encontrada'); + } + + // Generate new key + const plainKey = this.generatePlainKey(); + const keyIndex = this.getKeyIndex(plainKey); + const keyHash = await this.hashKey(plainKey); + + // Update with new key + const updated = await queryOne( + `UPDATE auth.api_keys + SET key_index = $1, key_hash = $2, updated_at = NOW() + WHERE id = $3 AND tenant_id = $4 + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, is_active, created_at, updated_at`, + [keyIndex, keyHash, id, tenantId] + ); + + if (!updated) { + throw new Error('Error al regenerar API key'); + } + + logger.info('API key regenerated', { apiKeyId: id }); + + return { + apiKey: updated, + plainKey, + }; + } +} + +export const apiKeysService = new ApiKeysService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/auth/index.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/index.ts index 7b61be2..2afcd75 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/auth/index.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/auth/index.ts @@ -1,3 +1,8 @@ export * from './auth.service.js'; export * from './auth.controller.js'; export { default as authRoutes } from './auth.routes.js'; + +// API Keys +export * from './apiKeys.service.js'; +export * from './apiKeys.controller.js'; +export { default as apiKeysRoutes } from './apiKeys.routes.js'; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/core/sequences.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/core/sequences.service.ts new file mode 100644 index 0000000..e50d52f --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/core/sequences.service.ts @@ -0,0 +1,371 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface Sequence { + id: string; + tenant_id: string; + code: string; + name: string; + prefix: string | null; + suffix: string | null; + next_number: number; + padding: number; + reset_period: 'none' | 'year' | 'month' | null; + last_reset_date: Date | null; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateSequenceDto { + code: string; + name: string; + prefix?: string; + suffix?: string; + start_number?: number; + padding?: number; + reset_period?: 'none' | 'year' | 'month'; +} + +export interface UpdateSequenceDto { + name?: string; + prefix?: string | null; + suffix?: string | null; + padding?: number; + reset_period?: 'none' | 'year' | 'month'; + is_active?: boolean; +} + +// ============================================================================ +// PREDEFINED SEQUENCE CODES +// ============================================================================ + +export const SEQUENCE_CODES = { + // Sales + SALES_ORDER: 'SO', + QUOTATION: 'QT', + + // Purchases + PURCHASE_ORDER: 'PO', + RFQ: 'RFQ', + + // Inventory + PICKING_IN: 'WH/IN', + PICKING_OUT: 'WH/OUT', + PICKING_INT: 'WH/INT', + INVENTORY_ADJ: 'INV/ADJ', + + // Financial + INVOICE_CUSTOMER: 'INV', + INVOICE_SUPPLIER: 'BILL', + PAYMENT: 'PAY', + JOURNAL_ENTRY: 'JE', + + // CRM + LEAD: 'LEAD', + OPPORTUNITY: 'OPP', + + // Projects + PROJECT: 'PRJ', + TASK: 'TASK', + + // HR + EMPLOYEE: 'EMP', + CONTRACT: 'CTR', +} as const; + +// ============================================================================ +// SERVICE +// ============================================================================ + +class SequencesService { + /** + * Get the next number in a sequence using the database function + * This is atomic and handles concurrent requests safely + */ + async getNextNumber( + sequenceCode: string, + tenantId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? async (sql: string, params: any[]) => { + const result = await client.query(sql, params); + return result.rows[0]; + } + : queryOne; + + // Use the database function for atomic sequence generation + const result = await executeQuery( + `SELECT core.generate_next_sequence($1, $2) as sequence_number`, + [sequenceCode, tenantId] + ); + + if (!result?.sequence_number) { + // Sequence doesn't exist, try to create it with default settings + logger.warn('Sequence not found, creating default', { sequenceCode, tenantId }); + + await this.ensureSequenceExists(sequenceCode, tenantId, client); + + // Try again + const retryResult = await executeQuery( + `SELECT core.generate_next_sequence($1, $2) as sequence_number`, + [sequenceCode, tenantId] + ); + + if (!retryResult?.sequence_number) { + throw new NotFoundError(`Secuencia ${sequenceCode} no encontrada`); + } + + return retryResult.sequence_number; + } + + logger.debug('Generated sequence number', { + sequenceCode, + number: result.sequence_number, + }); + + return result.sequence_number; + } + + /** + * Ensure a sequence exists, creating it with defaults if not + */ + async ensureSequenceExists( + sequenceCode: string, + tenantId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? async (sql: string, params: any[]) => { + const result = await client.query(sql, params); + return result.rows[0]; + } + : queryOne; + + // Check if exists + const existing = await executeQuery( + `SELECT id FROM core.sequences WHERE code = $1 AND tenant_id = $2`, + [sequenceCode, tenantId] + ); + + if (existing) return; + + // Create with defaults based on code + const defaults = this.getDefaultsForCode(sequenceCode); + + const insertQuery = client + ? async (sql: string, params: any[]) => client.query(sql, params) + : query; + + await insertQuery( + `INSERT INTO core.sequences (tenant_id, code, name, prefix, padding, next_number) + VALUES ($1, $2, $3, $4, $5, 1) + ON CONFLICT (tenant_id, code) DO NOTHING`, + [tenantId, sequenceCode, defaults.name, defaults.prefix, defaults.padding] + ); + + logger.info('Created default sequence', { sequenceCode, tenantId }); + } + + /** + * Get default settings for a sequence code + */ + private getDefaultsForCode(code: string): { name: string; prefix: string; padding: number } { + const defaults: Record = { + [SEQUENCE_CODES.SALES_ORDER]: { name: 'Órdenes de Venta', prefix: 'SO-', padding: 5 }, + [SEQUENCE_CODES.QUOTATION]: { name: 'Cotizaciones', prefix: 'QT-', padding: 5 }, + [SEQUENCE_CODES.PURCHASE_ORDER]: { name: 'Órdenes de Compra', prefix: 'PO-', padding: 5 }, + [SEQUENCE_CODES.RFQ]: { name: 'Solicitudes de Cotización', prefix: 'RFQ-', padding: 5 }, + [SEQUENCE_CODES.PICKING_IN]: { name: 'Recepciones', prefix: 'WH/IN/', padding: 5 }, + [SEQUENCE_CODES.PICKING_OUT]: { name: 'Entregas', prefix: 'WH/OUT/', padding: 5 }, + [SEQUENCE_CODES.PICKING_INT]: { name: 'Transferencias', prefix: 'WH/INT/', padding: 5 }, + [SEQUENCE_CODES.INVENTORY_ADJ]: { name: 'Ajustes de Inventario', prefix: 'ADJ/', padding: 5 }, + [SEQUENCE_CODES.INVOICE_CUSTOMER]: { name: 'Facturas de Cliente', prefix: 'INV/', padding: 6 }, + [SEQUENCE_CODES.INVOICE_SUPPLIER]: { name: 'Facturas de Proveedor', prefix: 'BILL/', padding: 6 }, + [SEQUENCE_CODES.PAYMENT]: { name: 'Pagos', prefix: 'PAY/', padding: 5 }, + [SEQUENCE_CODES.JOURNAL_ENTRY]: { name: 'Asientos Contables', prefix: 'JE/', padding: 6 }, + [SEQUENCE_CODES.LEAD]: { name: 'Prospectos', prefix: 'LEAD-', padding: 5 }, + [SEQUENCE_CODES.OPPORTUNITY]: { name: 'Oportunidades', prefix: 'OPP-', padding: 5 }, + [SEQUENCE_CODES.PROJECT]: { name: 'Proyectos', prefix: 'PRJ-', padding: 4 }, + [SEQUENCE_CODES.TASK]: { name: 'Tareas', prefix: 'TASK-', padding: 5 }, + [SEQUENCE_CODES.EMPLOYEE]: { name: 'Empleados', prefix: 'EMP-', padding: 4 }, + [SEQUENCE_CODES.CONTRACT]: { name: 'Contratos', prefix: 'CTR-', padding: 5 }, + }; + + return defaults[code] || { name: code, prefix: `${code}-`, padding: 5 }; + } + + /** + * Get all sequences for a tenant + */ + async findAll(tenantId: string): Promise { + return query( + `SELECT * FROM core.sequences + WHERE tenant_id = $1 + ORDER BY code`, + [tenantId] + ); + } + + /** + * Get a specific sequence by code + */ + async findByCode(code: string, tenantId: string): Promise { + return queryOne( + `SELECT * FROM core.sequences + WHERE code = $1 AND tenant_id = $2`, + [code, tenantId] + ); + } + + /** + * Create a new sequence + */ + async create(dto: CreateSequenceDto, tenantId: string): Promise { + // Check for existing + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new ValidationError(`Ya existe una secuencia con código ${dto.code}`); + } + + const sequence = await queryOne( + `INSERT INTO core.sequences ( + tenant_id, code, name, prefix, suffix, next_number, padding, reset_period + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + tenantId, + dto.code, + dto.name, + dto.prefix || null, + dto.suffix || null, + dto.start_number || 1, + dto.padding || 5, + dto.reset_period || 'none', + ] + ); + + logger.info('Sequence created', { code: dto.code, tenantId }); + + return sequence!; + } + + /** + * Update a sequence + */ + async update(code: string, dto: UpdateSequenceDto, tenantId: string): Promise { + const existing = await this.findByCode(code, tenantId); + if (!existing) { + throw new NotFoundError('Secuencia no encontrada'); + } + + const updates: string[] = ['updated_at = NOW()']; + const params: any[] = []; + let idx = 1; + + if (dto.name !== undefined) { + updates.push(`name = $${idx++}`); + params.push(dto.name); + } + if (dto.prefix !== undefined) { + updates.push(`prefix = $${idx++}`); + params.push(dto.prefix); + } + if (dto.suffix !== undefined) { + updates.push(`suffix = $${idx++}`); + params.push(dto.suffix); + } + if (dto.padding !== undefined) { + updates.push(`padding = $${idx++}`); + params.push(dto.padding); + } + if (dto.reset_period !== undefined) { + updates.push(`reset_period = $${idx++}`); + params.push(dto.reset_period); + } + if (dto.is_active !== undefined) { + updates.push(`is_active = $${idx++}`); + params.push(dto.is_active); + } + + params.push(code, tenantId); + + const updated = await queryOne( + `UPDATE core.sequences + SET ${updates.join(', ')} + WHERE code = $${idx++} AND tenant_id = $${idx} + RETURNING *`, + params + ); + + return updated!; + } + + /** + * Reset a sequence to a specific number + */ + async reset(code: string, tenantId: string, newNumber: number = 1): Promise { + const updated = await queryOne( + `UPDATE core.sequences + SET next_number = $1, last_reset_date = NOW(), updated_at = NOW() + WHERE code = $2 AND tenant_id = $3 + RETURNING *`, + [newNumber, code, tenantId] + ); + + if (!updated) { + throw new NotFoundError('Secuencia no encontrada'); + } + + logger.info('Sequence reset', { code, tenantId, newNumber }); + + return updated; + } + + /** + * Preview what the next number would be (without incrementing) + */ + async preview(code: string, tenantId: string): Promise { + const sequence = await this.findByCode(code, tenantId); + if (!sequence) { + throw new NotFoundError('Secuencia no encontrada'); + } + + const paddedNumber = String(sequence.next_number).padStart(sequence.padding, '0'); + const prefix = sequence.prefix || ''; + const suffix = sequence.suffix || ''; + + return `${prefix}${paddedNumber}${suffix}`; + } + + /** + * Initialize all standard sequences for a new tenant + */ + async initializeForTenant(tenantId: string): Promise { + const client = await getClient(); + + try { + await client.query('BEGIN'); + + for (const [key, code] of Object.entries(SEQUENCE_CODES)) { + await this.ensureSequenceExists(code, tenantId, client); + } + + await client.query('COMMIT'); + logger.info('Initialized sequences for tenant', { tenantId, count: Object.keys(SEQUENCE_CODES).length }); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} + +export const sequencesService = new SequencesService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/financial/fiscalPeriods.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/financial/fiscalPeriods.service.ts new file mode 100644 index 0000000..f286cba --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/financial/fiscalPeriods.service.ts @@ -0,0 +1,369 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type FiscalPeriodStatus = 'open' | 'closed'; + +export interface FiscalYear { + id: string; + tenant_id: string; + company_id: string; + name: string; + code: string; + date_from: Date; + date_to: Date; + status: FiscalPeriodStatus; + created_at: Date; +} + +export interface FiscalPeriod { + id: string; + tenant_id: string; + fiscal_year_id: string; + fiscal_year_name?: string; + code: string; + name: string; + date_from: Date; + date_to: Date; + status: FiscalPeriodStatus; + closed_at: Date | null; + closed_by: string | null; + closed_by_name?: string; + created_at: Date; +} + +export interface CreateFiscalYearDto { + company_id: string; + name: string; + code: string; + date_from: string; + date_to: string; +} + +export interface CreateFiscalPeriodDto { + fiscal_year_id: string; + code: string; + name: string; + date_from: string; + date_to: string; +} + +export interface FiscalPeriodFilters { + company_id?: string; + fiscal_year_id?: string; + status?: FiscalPeriodStatus; + date_from?: string; + date_to?: string; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class FiscalPeriodsService { + // ==================== FISCAL YEARS ==================== + + async findAllYears(tenantId: string, companyId?: string): Promise { + let sql = ` + SELECT * FROM financial.fiscal_years + WHERE tenant_id = $1 + `; + const params: any[] = [tenantId]; + + if (companyId) { + sql += ` AND company_id = $2`; + params.push(companyId); + } + + sql += ` ORDER BY date_from DESC`; + + return query(sql, params); + } + + async findYearById(id: string, tenantId: string): Promise { + const year = await queryOne( + `SELECT * FROM financial.fiscal_years WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!year) { + throw new NotFoundError('Año fiscal no encontrado'); + } + + return year; + } + + async createYear(dto: CreateFiscalYearDto, tenantId: string, userId: string): Promise { + // Check for overlapping years + const overlapping = await queryOne<{ id: string }>( + `SELECT id FROM financial.fiscal_years + WHERE tenant_id = $1 AND company_id = $2 + AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`, + [tenantId, dto.company_id, dto.date_from, dto.date_to] + ); + + if (overlapping) { + throw new ConflictError('Ya existe un año fiscal que se superpone con estas fechas'); + } + + const year = await queryOne( + `INSERT INTO financial.fiscal_years ( + tenant_id, company_id, name, code, date_from, date_to, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.code, dto.date_from, dto.date_to, userId] + ); + + logger.info('Fiscal year created', { yearId: year?.id, name: dto.name }); + + return year!; + } + + // ==================== FISCAL PERIODS ==================== + + async findAllPeriods(tenantId: string, filters: FiscalPeriodFilters = {}): Promise { + const conditions: string[] = ['fp.tenant_id = $1']; + const params: any[] = [tenantId]; + let idx = 2; + + if (filters.fiscal_year_id) { + conditions.push(`fp.fiscal_year_id = $${idx++}`); + params.push(filters.fiscal_year_id); + } + + if (filters.company_id) { + conditions.push(`fy.company_id = $${idx++}`); + params.push(filters.company_id); + } + + if (filters.status) { + conditions.push(`fp.status = $${idx++}`); + params.push(filters.status); + } + + if (filters.date_from) { + conditions.push(`fp.date_from >= $${idx++}`); + params.push(filters.date_from); + } + + if (filters.date_to) { + conditions.push(`fp.date_to <= $${idx++}`); + params.push(filters.date_to); + } + + return query( + `SELECT fp.*, + fy.name as fiscal_year_name, + u.full_name as closed_by_name + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + LEFT JOIN auth.users u ON fp.closed_by = u.id + WHERE ${conditions.join(' AND ')} + ORDER BY fp.date_from DESC`, + params + ); + } + + async findPeriodById(id: string, tenantId: string): Promise { + const period = await queryOne( + `SELECT fp.*, + fy.name as fiscal_year_name, + u.full_name as closed_by_name + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + LEFT JOIN auth.users u ON fp.closed_by = u.id + WHERE fp.id = $1 AND fp.tenant_id = $2`, + [id, tenantId] + ); + + if (!period) { + throw new NotFoundError('Período fiscal no encontrado'); + } + + return period; + } + + async findPeriodByDate(date: Date, companyId: string, tenantId: string): Promise { + return queryOne( + `SELECT fp.* + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + WHERE fp.tenant_id = $1 + AND fy.company_id = $2 + AND $3::date BETWEEN fp.date_from AND fp.date_to`, + [tenantId, companyId, date] + ); + } + + async createPeriod(dto: CreateFiscalPeriodDto, tenantId: string, userId: string): Promise { + // Verify fiscal year exists + await this.findYearById(dto.fiscal_year_id, tenantId); + + // Check for overlapping periods in the same year + const overlapping = await queryOne<{ id: string }>( + `SELECT id FROM financial.fiscal_periods + WHERE tenant_id = $1 AND fiscal_year_id = $2 + AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`, + [tenantId, dto.fiscal_year_id, dto.date_from, dto.date_to] + ); + + if (overlapping) { + throw new ConflictError('Ya existe un período que se superpone con estas fechas'); + } + + const period = await queryOne( + `INSERT INTO financial.fiscal_periods ( + tenant_id, fiscal_year_id, code, name, date_from, date_to, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, dto.fiscal_year_id, dto.code, dto.name, dto.date_from, dto.date_to, userId] + ); + + logger.info('Fiscal period created', { periodId: period?.id, name: dto.name }); + + return period!; + } + + // ==================== PERIOD OPERATIONS ==================== + + /** + * Close a fiscal period + * Uses database function for validation + */ + async closePeriod(periodId: string, tenantId: string, userId: string): Promise { + // Verify period exists and belongs to tenant + await this.findPeriodById(periodId, tenantId); + + // Use database function for atomic close with validations + const result = await queryOne( + `SELECT * FROM financial.close_fiscal_period($1, $2)`, + [periodId, userId] + ); + + if (!result) { + throw new Error('Error al cerrar período'); + } + + logger.info('Fiscal period closed', { periodId, userId }); + + return result; + } + + /** + * Reopen a fiscal period (admin only) + */ + async reopenPeriod(periodId: string, tenantId: string, userId: string, reason?: string): Promise { + // Verify period exists and belongs to tenant + await this.findPeriodById(periodId, tenantId); + + // Use database function for atomic reopen with audit + const result = await queryOne( + `SELECT * FROM financial.reopen_fiscal_period($1, $2, $3)`, + [periodId, userId, reason] + ); + + if (!result) { + throw new Error('Error al reabrir período'); + } + + logger.warn('Fiscal period reopened', { periodId, userId, reason }); + + return result; + } + + /** + * Get statistics for a period + */ + async getPeriodStats(periodId: string, tenantId: string): Promise<{ + total_entries: number; + draft_entries: number; + posted_entries: number; + total_debit: number; + total_credit: number; + }> { + const stats = await queryOne<{ + total_entries: string; + draft_entries: string; + posted_entries: string; + total_debit: string; + total_credit: string; + }>( + `SELECT + COUNT(*) as total_entries, + COUNT(*) FILTER (WHERE status = 'draft') as draft_entries, + COUNT(*) FILTER (WHERE status = 'posted') as posted_entries, + COALESCE(SUM(total_debit), 0) as total_debit, + COALESCE(SUM(total_credit), 0) as total_credit + FROM financial.journal_entries + WHERE fiscal_period_id = $1 AND tenant_id = $2`, + [periodId, tenantId] + ); + + return { + total_entries: parseInt(stats?.total_entries || '0', 10), + draft_entries: parseInt(stats?.draft_entries || '0', 10), + posted_entries: parseInt(stats?.posted_entries || '0', 10), + total_debit: parseFloat(stats?.total_debit || '0'), + total_credit: parseFloat(stats?.total_credit || '0'), + }; + } + + /** + * Generate monthly periods for a fiscal year + */ + async generateMonthlyPeriods(fiscalYearId: string, tenantId: string, userId: string): Promise { + const year = await this.findYearById(fiscalYearId, tenantId); + + const startDate = new Date(year.date_from); + const endDate = new Date(year.date_to); + const periods: FiscalPeriod[] = []; + + let currentDate = new Date(startDate); + let periodNum = 1; + + while (currentDate <= endDate) { + const periodStart = new Date(currentDate); + const periodEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0); + + // Don't exceed the fiscal year end + if (periodEnd > endDate) { + periodEnd.setTime(endDate.getTime()); + } + + const monthNames = [ + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' + ]; + + try { + const period = await this.createPeriod({ + fiscal_year_id: fiscalYearId, + code: String(periodNum).padStart(2, '0'), + name: `${monthNames[periodStart.getMonth()]} ${periodStart.getFullYear()}`, + date_from: periodStart.toISOString().split('T')[0], + date_to: periodEnd.toISOString().split('T')[0], + }, tenantId, userId); + + periods.push(period); + } catch (error) { + // Skip if period already exists (overlapping check will fail) + logger.debug('Period creation skipped', { periodNum, error }); + } + + // Move to next month + currentDate.setMonth(currentDate.getMonth() + 1); + currentDate.setDate(1); + periodNum++; + } + + logger.info('Generated monthly periods', { fiscalYearId, count: periods.length }); + + return periods; + } +} + +export const fiscalPeriodsService = new FiscalPeriodsService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/index.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/index.ts index 5c1766b..40c84f5 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/index.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/index.ts @@ -10,5 +10,7 @@ export { export * from './pickings.service.js'; export * from './lots.service.js'; export * from './adjustments.service.js'; +export * from './valuation.service.js'; export * from './inventory.controller.js'; +export * from './valuation.controller.js'; export { default as inventoryRoutes } from './inventory.routes.js'; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/inventory.routes.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/inventory.routes.ts index d2fd759..6f45bf6 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/inventory.routes.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/inventory.routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { inventoryController } from './inventory.controller.js'; +import { valuationController } from './valuation.controller.js'; import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; const router = Router(); @@ -149,4 +150,25 @@ router.delete('/adjustments/:id', requireRoles('admin', 'super_admin'), (req, re inventoryController.deleteAdjustment(req, res, next) ); +// ========== VALUATION ========== +router.get('/valuation/cost', (req, res, next) => valuationController.getProductCost(req, res, next)); + +router.get('/valuation/report', (req, res, next) => valuationController.getCompanyReport(req, res, next)); + +router.get('/valuation/products/:productId/summary', (req, res, next) => + valuationController.getProductSummary(req, res, next) +); + +router.get('/valuation/products/:productId/layers', (req, res, next) => + valuationController.getProductLayers(req, res, next) +); + +router.post('/valuation/layers', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + valuationController.createLayer(req, res, next) +); + +router.post('/valuation/consume', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + valuationController.consumeFifo(req, res, next) +); + export default router; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.controller.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.controller.ts new file mode 100644 index 0000000..01a9c7d --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.controller.ts @@ -0,0 +1,230 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { valuationService, CreateValuationLayerDto } from './valuation.service.js'; +import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const getProductCostSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), +}); + +const createLayerSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), + quantity: z.number().positive(), + unit_cost: z.number().nonnegative(), + stock_move_id: z.string().uuid().optional(), + description: z.string().max(255).optional(), +}); + +const consumeFifoSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), + quantity: z.number().positive(), +}); + +const productLayersSchema = z.object({ + company_id: z.string().uuid(), + include_empty: z.enum(['true', 'false']).optional(), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ValuationController { + /** + * Get cost for a product based on its valuation method + * GET /api/inventory/valuation/cost + */ + async getProductCost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = getProductCostSchema.safeParse(req.query); + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const { product_id, company_id } = validation.data; + const result = await valuationService.getProductCost( + product_id, + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get valuation summary for a product + * GET /api/inventory/valuation/products/:productId/summary + */ + async getProductSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { productId } = req.params; + const { company_id } = req.query; + + if (!company_id || typeof company_id !== 'string') { + throw new ValidationError('company_id es requerido'); + } + + const result = await valuationService.getProductValuationSummary( + productId, + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get valuation layers for a product + * GET /api/inventory/valuation/products/:productId/layers + */ + async getProductLayers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { productId } = req.params; + const validation = productLayersSchema.safeParse(req.query); + + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const { company_id, include_empty } = validation.data; + const includeEmpty = include_empty === 'true'; + + const result = await valuationService.getProductLayers( + productId, + company_id, + req.user!.tenantId, + includeEmpty + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get company-wide valuation report + * GET /api/inventory/valuation/report + */ + async getCompanyReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { company_id } = req.query; + + if (!company_id || typeof company_id !== 'string') { + throw new ValidationError('company_id es requerido'); + } + + const result = await valuationService.getCompanyValuationReport( + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + meta: { + total: result.length, + totalValue: result.reduce((sum, p) => sum + Number(p.total_value), 0), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Create a valuation layer manually (for adjustments) + * POST /api/inventory/valuation/layers + */ + async createLayer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createLayerSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const dto: CreateValuationLayerDto = validation.data; + + const result = await valuationService.createLayer( + dto, + req.user!.tenantId, + req.user!.userId + ); + + const response: ApiResponse = { + success: true, + data: result, + message: 'Capa de valoración creada', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * Consume stock using FIFO (for testing/manual adjustments) + * POST /api/inventory/valuation/consume + */ + async consumeFifo(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = consumeFifoSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const { product_id, company_id, quantity } = validation.data; + + const result = await valuationService.consumeFifo( + product_id, + company_id, + quantity, + req.user!.tenantId, + req.user!.userId + ); + + const response: ApiResponse = { + success: true, + data: result, + message: `Consumidas ${result.layers_consumed.length} capas FIFO`, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const valuationController = new ValuationController(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.service.ts new file mode 100644 index 0000000..a4909a7 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/inventory/valuation.service.ts @@ -0,0 +1,522 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ValuationMethod = 'standard' | 'fifo' | 'average'; + +export interface StockValuationLayer { + id: string; + tenant_id: string; + product_id: string; + company_id: string; + quantity: number; + unit_cost: number; + value: number; + remaining_qty: number; + remaining_value: number; + stock_move_id?: string; + description?: string; + account_move_id?: string; + journal_entry_id?: string; + created_at: Date; +} + +export interface CreateValuationLayerDto { + product_id: string; + company_id: string; + quantity: number; + unit_cost: number; + stock_move_id?: string; + description?: string; +} + +export interface ValuationSummary { + product_id: string; + product_name: string; + product_code?: string; + total_quantity: number; + total_value: number; + average_cost: number; + valuation_method: ValuationMethod; + layer_count: number; +} + +export interface FifoConsumptionResult { + layers_consumed: { + layer_id: string; + quantity_consumed: number; + unit_cost: number; + value_consumed: number; + }[]; + total_cost: number; + weighted_average_cost: number; +} + +export interface ProductCostResult { + product_id: string; + valuation_method: ValuationMethod; + standard_cost: number; + fifo_cost?: number; + average_cost: number; + recommended_cost: number; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ValuationService { + /** + * Create a new valuation layer (for incoming stock) + * Used when receiving products via purchase orders or inventory adjustments + */ + async createLayer( + dto: CreateValuationLayerDto, + tenantId: string, + userId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? (sql: string, params: any[]) => client.query(sql, params).then(r => r.rows[0]) + : queryOne; + + const value = dto.quantity * dto.unit_cost; + + const layer = await executeQuery( + `INSERT INTO inventory.stock_valuation_layers ( + tenant_id, product_id, company_id, quantity, unit_cost, value, + remaining_qty, remaining_value, stock_move_id, description, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $4, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + dto.product_id, + dto.company_id, + dto.quantity, + dto.unit_cost, + value, + dto.stock_move_id, + dto.description, + userId, + ] + ); + + logger.info('Valuation layer created', { + layerId: layer?.id, + productId: dto.product_id, + quantity: dto.quantity, + unitCost: dto.unit_cost, + }); + + return layer as StockValuationLayer; + } + + /** + * Consume stock using FIFO method + * Returns the layers consumed and total cost + */ + async consumeFifo( + productId: string, + companyId: string, + quantity: number, + tenantId: string, + userId: string, + client?: PoolClient + ): Promise { + const dbClient = client || await getClient(); + const shouldReleaseClient = !client; + + try { + if (!client) { + await dbClient.query('BEGIN'); + } + + // Get available layers ordered by creation date (FIFO) + const layersResult = await dbClient.query( + `SELECT * FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0 + ORDER BY created_at ASC + FOR UPDATE`, + [productId, companyId, tenantId] + ); + + const layers = layersResult.rows as StockValuationLayer[]; + let remainingToConsume = quantity; + const consumedLayers: FifoConsumptionResult['layers_consumed'] = []; + let totalCost = 0; + + for (const layer of layers) { + if (remainingToConsume <= 0) break; + + const consumeFromLayer = Math.min(remainingToConsume, Number(layer.remaining_qty)); + const valueConsumed = consumeFromLayer * Number(layer.unit_cost); + + // Update layer + await dbClient.query( + `UPDATE inventory.stock_valuation_layers + SET remaining_qty = remaining_qty - $1, + remaining_value = remaining_value - $2, + updated_at = NOW(), + updated_by = $3 + WHERE id = $4`, + [consumeFromLayer, valueConsumed, userId, layer.id] + ); + + consumedLayers.push({ + layer_id: layer.id, + quantity_consumed: consumeFromLayer, + unit_cost: Number(layer.unit_cost), + value_consumed: valueConsumed, + }); + + totalCost += valueConsumed; + remainingToConsume -= consumeFromLayer; + } + + if (remainingToConsume > 0) { + // Not enough stock in layers - this is a warning, not an error + // The stock might exist without valuation layers (e.g., initial data) + logger.warn('Insufficient valuation layers for FIFO consumption', { + productId, + requestedQty: quantity, + availableQty: quantity - remainingToConsume, + }); + } + + if (!client) { + await dbClient.query('COMMIT'); + } + + const weightedAvgCost = quantity > 0 ? totalCost / (quantity - remainingToConsume) : 0; + + return { + layers_consumed: consumedLayers, + total_cost: totalCost, + weighted_average_cost: weightedAvgCost, + }; + } catch (error) { + if (!client) { + await dbClient.query('ROLLBACK'); + } + throw error; + } finally { + if (shouldReleaseClient) { + dbClient.release(); + } + } + } + + /** + * Calculate the current cost of a product based on its valuation method + */ + async getProductCost( + productId: string, + companyId: string, + tenantId: string + ): Promise { + // Get product with its valuation method and standard cost + const product = await queryOne<{ + id: string; + valuation_method: ValuationMethod; + cost_price: number; + }>( + `SELECT id, valuation_method, cost_price + FROM inventory.products + WHERE id = $1 AND tenant_id = $2`, + [productId, tenantId] + ); + + if (!product) { + throw new NotFoundError('Producto no encontrado'); + } + + // Get FIFO cost (oldest layer's unit cost) + const oldestLayer = await queryOne<{ unit_cost: number }>( + `SELECT unit_cost FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0 + ORDER BY created_at ASC + LIMIT 1`, + [productId, companyId, tenantId] + ); + + // Get average cost from all layers + const avgResult = await queryOne<{ avg_cost: number; total_qty: number }>( + `SELECT + CASE WHEN SUM(remaining_qty) > 0 + THEN SUM(remaining_value) / SUM(remaining_qty) + ELSE 0 + END as avg_cost, + SUM(remaining_qty) as total_qty + FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0`, + [productId, companyId, tenantId] + ); + + const standardCost = Number(product.cost_price) || 0; + const fifoCost = oldestLayer ? Number(oldestLayer.unit_cost) : undefined; + const averageCost = Number(avgResult?.avg_cost) || 0; + + // Determine recommended cost based on valuation method + let recommendedCost: number; + switch (product.valuation_method) { + case 'fifo': + recommendedCost = fifoCost ?? standardCost; + break; + case 'average': + recommendedCost = averageCost > 0 ? averageCost : standardCost; + break; + case 'standard': + default: + recommendedCost = standardCost; + break; + } + + return { + product_id: productId, + valuation_method: product.valuation_method, + standard_cost: standardCost, + fifo_cost: fifoCost, + average_cost: averageCost, + recommended_cost: recommendedCost, + }; + } + + /** + * Get valuation summary for a product + */ + async getProductValuationSummary( + productId: string, + companyId: string, + tenantId: string + ): Promise { + const result = await queryOne( + `SELECT + p.id as product_id, + p.name as product_name, + p.code as product_code, + p.valuation_method, + COALESCE(SUM(svl.remaining_qty), 0) as total_quantity, + COALESCE(SUM(svl.remaining_value), 0) as total_value, + CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0 + THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty) + ELSE p.cost_price + END as average_cost, + COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count + FROM inventory.products p + LEFT JOIN inventory.stock_valuation_layers svl + ON p.id = svl.product_id + AND svl.company_id = $2 + AND svl.tenant_id = $3 + WHERE p.id = $1 AND p.tenant_id = $3 + GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price`, + [productId, companyId, tenantId] + ); + + return result; + } + + /** + * Get all valuation layers for a product + */ + async getProductLayers( + productId: string, + companyId: string, + tenantId: string, + includeEmpty: boolean = false + ): Promise { + const whereClause = includeEmpty + ? '' + : 'AND remaining_qty > 0'; + + return query( + `SELECT * FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + ${whereClause} + ORDER BY created_at ASC`, + [productId, companyId, tenantId] + ); + } + + /** + * Get inventory valuation report for a company + */ + async getCompanyValuationReport( + companyId: string, + tenantId: string + ): Promise { + return query( + `SELECT + p.id as product_id, + p.name as product_name, + p.code as product_code, + p.valuation_method, + COALESCE(SUM(svl.remaining_qty), 0) as total_quantity, + COALESCE(SUM(svl.remaining_value), 0) as total_value, + CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0 + THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty) + ELSE p.cost_price + END as average_cost, + COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count + FROM inventory.products p + LEFT JOIN inventory.stock_valuation_layers svl + ON p.id = svl.product_id + AND svl.company_id = $1 + AND svl.tenant_id = $2 + WHERE p.tenant_id = $2 + AND p.product_type = 'storable' + AND p.active = true + GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price + HAVING COALESCE(SUM(svl.remaining_qty), 0) > 0 + ORDER BY p.name`, + [companyId, tenantId] + ); + } + + /** + * Update average cost on product after valuation changes + * Call this after creating layers or consuming stock + */ + async updateProductAverageCost( + productId: string, + companyId: string, + tenantId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? (sql: string, params: any[]) => client.query(sql, params) + : query; + + // Only update products using average cost method + await executeQuery( + `UPDATE inventory.products p + SET cost_price = ( + SELECT CASE WHEN SUM(svl.remaining_qty) > 0 + THEN SUM(svl.remaining_value) / SUM(svl.remaining_qty) + ELSE p.cost_price + END + FROM inventory.stock_valuation_layers svl + WHERE svl.product_id = p.id + AND svl.company_id = $2 + AND svl.tenant_id = $3 + AND svl.remaining_qty > 0 + ), + updated_at = NOW() + WHERE p.id = $1 + AND p.tenant_id = $3 + AND p.valuation_method = 'average'`, + [productId, companyId, tenantId] + ); + } + + /** + * Process stock move for valuation + * Creates or consumes valuation layers based on move direction + */ + async processStockMoveValuation( + moveId: string, + tenantId: string, + userId: string + ): Promise { + const move = await queryOne<{ + id: string; + product_id: string; + product_qty: number; + location_id: string; + location_dest_id: string; + company_id: string; + }>( + `SELECT sm.id, sm.product_id, sm.product_qty, + sm.location_id, sm.location_dest_id, + p.company_id + FROM inventory.stock_moves sm + JOIN inventory.pickings p ON sm.picking_id = p.id + WHERE sm.id = $1 AND sm.tenant_id = $2`, + [moveId, tenantId] + ); + + if (!move) { + throw new NotFoundError('Movimiento no encontrado'); + } + + // Get location types + const [srcLoc, destLoc] = await Promise.all([ + queryOne<{ location_type: string }>( + 'SELECT location_type FROM inventory.locations WHERE id = $1', + [move.location_id] + ), + queryOne<{ location_type: string }>( + 'SELECT location_type FROM inventory.locations WHERE id = $1', + [move.location_dest_id] + ), + ]); + + const srcIsInternal = srcLoc?.location_type === 'internal'; + const destIsInternal = destLoc?.location_type === 'internal'; + + // Get product cost for new layers + const product = await queryOne<{ cost_price: number; valuation_method: string }>( + 'SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1', + [move.product_id] + ); + + if (!product) return; + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Incoming to internal location (create layer) + if (!srcIsInternal && destIsInternal) { + await this.createLayer({ + product_id: move.product_id, + company_id: move.company_id, + quantity: Number(move.product_qty), + unit_cost: Number(product.cost_price), + stock_move_id: move.id, + description: `Recepción - Move ${move.id}`, + }, tenantId, userId, client); + } + + // Outgoing from internal location (consume layer with FIFO) + if (srcIsInternal && !destIsInternal) { + if (product.valuation_method === 'fifo' || product.valuation_method === 'average') { + await this.consumeFifo( + move.product_id, + move.company_id, + Number(move.product_qty), + tenantId, + userId, + client + ); + } + } + + // Update average cost if using that method + if (product.valuation_method === 'average') { + await this.updateProductAverageCost( + move.product_id, + move.company_id, + tenantId, + client + ); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} + +export const valuationService = new ValuationService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/index.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/index.ts index 09b6e6e..c8d31d8 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/index.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/index.ts @@ -1,3 +1,5 @@ export * from './partners.service.js'; export * from './partners.controller.js'; +export * from './ranking.service.js'; +export * from './ranking.controller.js'; export { default as partnersRoutes } from './partners.routes.js'; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/partners.routes.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/partners.routes.ts index b0013ce..d4c65f7 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/partners.routes.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/partners.routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { partnersController } from './partners.controller.js'; +import { rankingController } from './ranking.controller.js'; import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; const router = Router(); @@ -7,6 +8,56 @@ const router = Router(); // All routes require authentication router.use(authenticate); +// ============================================================================ +// RANKING ROUTES (must be before /:id routes to avoid conflicts) +// ============================================================================ + +// Calculate rankings (admin, manager) +router.post('/rankings/calculate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + rankingController.calculateRankings(req, res, next) +); + +// Get all rankings +router.get('/rankings', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.findRankings(req, res, next) +); + +// Top partners +router.get('/rankings/top/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getTopCustomers(req, res, next) +); +router.get('/rankings/top/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getTopSuppliers(req, res, next) +); + +// ABC distribution +router.get('/rankings/abc/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getCustomerABCDistribution(req, res, next) +); +router.get('/rankings/abc/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getSupplierABCDistribution(req, res, next) +); + +// Partners by ABC +router.get('/rankings/abc/customers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getCustomersByABC(req, res, next) +); +router.get('/rankings/abc/suppliers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getSuppliersByABC(req, res, next) +); + +// Partner-specific ranking +router.get('/rankings/partner/:partnerId', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.findPartnerRanking(req, res, next) +); +router.get('/rankings/partner/:partnerId/history', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getPartnerHistory(req, res, next) +); + +// ============================================================================ +// PARTNER ROUTES +// ============================================================================ + // Convenience endpoints for customers and suppliers router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next)); router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next)); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.controller.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.controller.ts new file mode 100644 index 0000000..95e15c1 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.controller.ts @@ -0,0 +1,368 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AuthenticatedRequest } from '../../shared/types/index.js'; +import { rankingService, ABCClassification } from './ranking.service.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const calculateRankingsSchema = z.object({ + company_id: z.string().uuid().optional(), + period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}); + +const rankingFiltersSchema = z.object({ + company_id: z.string().uuid().optional(), + period_start: z.string().optional(), + period_end: z.string().optional(), + customer_abc: z.enum(['A', 'B', 'C']).optional(), + supplier_abc: z.enum(['A', 'B', 'C']).optional(), + min_sales: z.coerce.number().min(0).optional(), + min_purchases: z.coerce.number().min(0).optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class RankingController { + /** + * POST /rankings/calculate + * Calculate partner rankings + */ + async calculateRankings( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id, period_start, period_end } = calculateRankingsSchema.parse(req.body); + const tenantId = req.user!.tenantId; + + const result = await rankingService.calculateRankings( + tenantId, + company_id, + period_start, + period_end + ); + + res.json({ + success: true, + message: 'Rankings calculados exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings + * List all rankings with filters + */ + async findRankings( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const filters = rankingFiltersSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const { data, total } = await rankingService.findRankings(tenantId, filters); + + res.json({ + success: true, + data, + pagination: { + page: filters.page, + limit: filters.limit, + total, + totalPages: Math.ceil(total / filters.limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/partner/:partnerId + * Get ranking for a specific partner + */ + async findPartnerRanking( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { partnerId } = req.params; + const { period_start, period_end } = req.query as { + period_start?: string; + period_end?: string; + }; + const tenantId = req.user!.tenantId; + + const ranking = await rankingService.findPartnerRanking( + partnerId, + tenantId, + period_start, + period_end + ); + + if (!ranking) { + res.status(404).json({ + success: false, + error: 'No se encontró ranking para este contacto', + }); + return; + } + + res.json({ + success: true, + data: ranking, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/partner/:partnerId/history + * Get ranking history for a partner + */ + async getPartnerHistory( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { partnerId } = req.params; + const limit = parseInt(req.query.limit as string) || 12; + const tenantId = req.user!.tenantId; + + const history = await rankingService.getPartnerRankingHistory( + partnerId, + tenantId, + Math.min(limit, 24) + ); + + res.json({ + success: true, + data: history, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/top/customers + * Get top customers + */ + async getTopCustomers( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const limit = parseInt(req.query.limit as string) || 10; + const tenantId = req.user!.tenantId; + + const data = await rankingService.getTopPartners( + tenantId, + 'customers', + Math.min(limit, 50) + ); + + res.json({ + success: true, + data, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/top/suppliers + * Get top suppliers + */ + async getTopSuppliers( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const limit = parseInt(req.query.limit as string) || 10; + const tenantId = req.user!.tenantId; + + const data = await rankingService.getTopPartners( + tenantId, + 'suppliers', + Math.min(limit, 50) + ); + + res.json({ + success: true, + data, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/customers + * Get ABC distribution for customers + */ + async getCustomerABCDistribution( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id } = req.query as { company_id?: string }; + const tenantId = req.user!.tenantId; + + const distribution = await rankingService.getABCDistribution( + tenantId, + 'customers', + company_id + ); + + res.json({ + success: true, + data: distribution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/suppliers + * Get ABC distribution for suppliers + */ + async getSupplierABCDistribution( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id } = req.query as { company_id?: string }; + const tenantId = req.user!.tenantId; + + const distribution = await rankingService.getABCDistribution( + tenantId, + 'suppliers', + company_id + ); + + res.json({ + success: true, + data: distribution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/customers/:abc + * Get customers by ABC classification + */ + async getCustomersByABC( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const abc = req.params.abc.toUpperCase() as ABCClassification; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const tenantId = req.user!.tenantId; + + if (!['A', 'B', 'C'].includes(abc || '')) { + res.status(400).json({ + success: false, + error: 'Clasificación ABC inválida. Use A, B o C.', + }); + return; + } + + const { data, total } = await rankingService.findPartnersByABC( + tenantId, + abc, + 'customers', + page, + Math.min(limit, 100) + ); + + res.json({ + success: true, + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/suppliers/:abc + * Get suppliers by ABC classification + */ + async getSuppliersByABC( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const abc = req.params.abc.toUpperCase() as ABCClassification; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const tenantId = req.user!.tenantId; + + if (!['A', 'B', 'C'].includes(abc || '')) { + res.status(400).json({ + success: false, + error: 'Clasificación ABC inválida. Use A, B o C.', + }); + return; + } + + const { data, total } = await rankingService.findPartnersByABC( + tenantId, + abc, + 'suppliers', + page, + Math.min(limit, 100) + ); + + res.json({ + success: true, + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + next(error); + } + } +} + +export const rankingController = new RankingController(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.service.ts new file mode 100644 index 0000000..4a00a3c --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/partners/ranking.service.ts @@ -0,0 +1,373 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ABCClassification = 'A' | 'B' | 'C' | null; + +export interface PartnerRanking { + id: string; + tenant_id: string; + partner_id: string; + partner_name?: string; + company_id: string | null; + period_start: Date; + period_end: Date; + total_sales: number; + sales_order_count: number; + avg_order_value: number; + total_purchases: number; + purchase_order_count: number; + avg_purchase_value: number; + avg_payment_days: number | null; + on_time_payment_rate: number | null; + sales_rank: number | null; + purchase_rank: number | null; + customer_abc: ABCClassification; + supplier_abc: ABCClassification; + customer_score: number | null; + supplier_score: number | null; + overall_score: number | null; + sales_trend: number | null; + purchase_trend: number | null; + calculated_at: Date; +} + +export interface RankingCalculationResult { + partners_processed: number; + customers_ranked: number; + suppliers_ranked: number; +} + +export interface RankingFilters { + company_id?: string; + period_start?: string; + period_end?: string; + customer_abc?: ABCClassification; + supplier_abc?: ABCClassification; + min_sales?: number; + min_purchases?: number; + page?: number; + limit?: number; +} + +export interface TopPartner { + id: string; + tenant_id: string; + name: string; + email: string | null; + is_customer: boolean; + is_supplier: boolean; + customer_rank: number | null; + supplier_rank: number | null; + customer_abc: ABCClassification; + supplier_abc: ABCClassification; + total_sales_ytd: number; + total_purchases_ytd: number; + last_ranking_date: Date | null; + customer_category: string | null; + supplier_category: string | null; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class RankingService { + /** + * Calculate rankings for all partners in a tenant + * Uses the database function for atomic calculation + */ + async calculateRankings( + tenantId: string, + companyId?: string, + periodStart?: string, + periodEnd?: string + ): Promise { + const result = await queryOne<{ + partners_processed: string; + customers_ranked: string; + suppliers_ranked: string; + }>( + `SELECT * FROM core.calculate_partner_rankings($1, $2, $3, $4)`, + [ + tenantId, + companyId || null, + periodStart || null, + periodEnd || null, + ] + ); + + if (!result) { + throw new Error('Error calculando rankings'); + } + + logger.info('Partner rankings calculated', { + tenantId, + companyId, + periodStart, + periodEnd, + result, + }); + + return { + partners_processed: parseInt(result.partners_processed, 10), + customers_ranked: parseInt(result.customers_ranked, 10), + suppliers_ranked: parseInt(result.suppliers_ranked, 10), + }; + } + + /** + * Get rankings for a specific period + */ + async findRankings( + tenantId: string, + filters: RankingFilters = {} + ): Promise<{ data: PartnerRanking[]; total: number }> { + const { + company_id, + period_start, + period_end, + customer_abc, + supplier_abc, + min_sales, + min_purchases, + page = 1, + limit = 20, + } = filters; + + const conditions: string[] = ['pr.tenant_id = $1']; + const params: any[] = [tenantId]; + let idx = 2; + + if (company_id) { + conditions.push(`pr.company_id = $${idx++}`); + params.push(company_id); + } + + if (period_start) { + conditions.push(`pr.period_start >= $${idx++}`); + params.push(period_start); + } + + if (period_end) { + conditions.push(`pr.period_end <= $${idx++}`); + params.push(period_end); + } + + if (customer_abc) { + conditions.push(`pr.customer_abc = $${idx++}`); + params.push(customer_abc); + } + + if (supplier_abc) { + conditions.push(`pr.supplier_abc = $${idx++}`); + params.push(supplier_abc); + } + + if (min_sales !== undefined) { + conditions.push(`pr.total_sales >= $${idx++}`); + params.push(min_sales); + } + + if (min_purchases !== undefined) { + conditions.push(`pr.total_purchases >= $${idx++}`); + params.push(min_purchases); + } + + const whereClause = conditions.join(' AND '); + + // Count total + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM core.partner_rankings pr WHERE ${whereClause}`, + params + ); + + // Get data with pagination + const offset = (page - 1) * limit; + params.push(limit, offset); + + const data = await query( + `SELECT pr.*, + p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE ${whereClause} + ORDER BY pr.overall_score DESC NULLS LAST, pr.total_sales DESC + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + /** + * Get ranking for a specific partner + */ + async findPartnerRanking( + partnerId: string, + tenantId: string, + periodStart?: string, + periodEnd?: string + ): Promise { + let sql = ` + SELECT pr.*, p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE pr.partner_id = $1 AND pr.tenant_id = $2 + `; + const params: any[] = [partnerId, tenantId]; + + if (periodStart && periodEnd) { + sql += ` AND pr.period_start = $3 AND pr.period_end = $4`; + params.push(periodStart, periodEnd); + } else { + // Get most recent ranking + sql += ` ORDER BY pr.calculated_at DESC LIMIT 1`; + } + + return queryOne(sql, params); + } + + /** + * Get top partners (customers or suppliers) + */ + async getTopPartners( + tenantId: string, + type: 'customers' | 'suppliers', + limit: number = 10 + ): Promise { + const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank'; + const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; + + return query( + `SELECT * FROM core.top_partners_view + WHERE tenant_id = $1 AND ${orderColumn} IS NOT NULL + ORDER BY ${orderColumn} ASC + LIMIT $2`, + [tenantId, limit] + ); + } + + /** + * Get ABC distribution summary + */ + async getABCDistribution( + tenantId: string, + type: 'customers' | 'suppliers', + companyId?: string + ): Promise<{ + A: { count: number; total_value: number; percentage: number }; + B: { count: number; total_value: number; percentage: number }; + C: { count: number; total_value: number; percentage: number }; + }> { + const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; + const valueColumn = type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'; + + let whereClause = `tenant_id = $1 AND ${abcColumn} IS NOT NULL`; + const params: any[] = [tenantId]; + + if (companyId) { + // Note: company_id filter would need to be added if partners have company_id + // For now, we use the denormalized data on partners table + } + + const result = await query<{ + abc: string; + count: string; + total_value: string; + }>( + `SELECT + ${abcColumn} as abc, + COUNT(*) as count, + COALESCE(SUM(${valueColumn}), 0) as total_value + FROM core.partners + WHERE ${whereClause} AND deleted_at IS NULL + GROUP BY ${abcColumn} + ORDER BY ${abcColumn}`, + params + ); + + // Calculate totals + const grandTotal = result.reduce((sum, r) => sum + parseFloat(r.total_value), 0); + + const distribution = { + A: { count: 0, total_value: 0, percentage: 0 }, + B: { count: 0, total_value: 0, percentage: 0 }, + C: { count: 0, total_value: 0, percentage: 0 }, + }; + + for (const row of result) { + const abc = row.abc as 'A' | 'B' | 'C'; + if (abc in distribution) { + distribution[abc] = { + count: parseInt(row.count, 10), + total_value: parseFloat(row.total_value), + percentage: grandTotal > 0 ? (parseFloat(row.total_value) / grandTotal) * 100 : 0, + }; + } + } + + return distribution; + } + + /** + * Get ranking history for a partner + */ + async getPartnerRankingHistory( + partnerId: string, + tenantId: string, + limit: number = 12 + ): Promise { + return query( + `SELECT pr.*, p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE pr.partner_id = $1 AND pr.tenant_id = $2 + ORDER BY pr.period_end DESC + LIMIT $3`, + [partnerId, tenantId, limit] + ); + } + + /** + * Get partners by ABC classification + */ + async findPartnersByABC( + tenantId: string, + abc: ABCClassification, + type: 'customers' | 'suppliers', + page: number = 1, + limit: number = 20 + ): Promise<{ data: TopPartner[]; total: number }> { + const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; + const offset = (page - 1) * limit; + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM core.partners + WHERE tenant_id = $1 AND ${abcColumn} = $2 AND deleted_at IS NULL`, + [tenantId, abc] + ); + + const data = await query( + `SELECT * FROM core.top_partners_view + WHERE tenant_id = $1 AND ${abcColumn} = $2 + ORDER BY ${type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'} DESC + LIMIT $3 OFFSET $4`, + [tenantId, abc, limit, offset] + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } +} + +export const rankingService = new RankingService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/reports/index.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/index.ts new file mode 100644 index 0000000..b5d3f41 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/index.ts @@ -0,0 +1,3 @@ +export * from './reports.service.js'; +export * from './reports.controller.js'; +export { default as reportsRoutes } from './reports.routes.js'; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.controller.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..42e0286 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.controller.ts @@ -0,0 +1,434 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AuthenticatedRequest } from '../../shared/types/index.js'; +import { reportsService } from './reports.service.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const reportFiltersSchema = z.object({ + report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(), + category: z.string().optional(), + is_system: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +const createDefinitionSchema = z.object({ + code: z.string().min(1).max(50), + name: z.string().min(1).max(255), + description: z.string().optional(), + report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(), + category: z.string().optional(), + base_query: z.string().optional(), + query_function: z.string().optional(), + parameters_schema: z.record(z.any()).optional(), + columns_config: z.array(z.any()).optional(), + export_formats: z.array(z.string()).optional(), + required_permissions: z.array(z.string()).optional(), +}); + +const executeReportSchema = z.object({ + definition_id: z.string().uuid(), + parameters: z.record(z.any()), +}); + +const createScheduleSchema = z.object({ + definition_id: z.string().uuid(), + name: z.string().min(1).max(255), + cron_expression: z.string().min(1), + default_parameters: z.record(z.any()).optional(), + company_id: z.string().uuid().optional(), + timezone: z.string().optional(), + delivery_method: z.enum(['none', 'email', 'storage', 'webhook']).optional(), + delivery_config: z.record(z.any()).optional(), +}); + +const trialBalanceSchema = z.object({ + company_id: z.string().uuid().optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + include_zero: z.coerce.boolean().optional(), +}); + +const generalLedgerSchema = z.object({ + company_id: z.string().uuid().optional(), + account_id: z.string().uuid(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ReportsController { + // ==================== DEFINITIONS ==================== + + /** + * GET /reports/definitions + * List all report definitions + */ + async findAllDefinitions( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const filters = reportFiltersSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const { data, total } = await reportsService.findAllDefinitions(tenantId, filters); + + res.json({ + success: true, + data, + pagination: { + page: filters.page, + limit: filters.limit, + total, + totalPages: Math.ceil(total / filters.limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/definitions/:id + * Get a specific report definition + */ + async findDefinitionById( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + const definition = await reportsService.findDefinitionById(id, tenantId); + + res.json({ + success: true, + data: definition, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/definitions + * Create a custom report definition + */ + async createDefinition( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = createDefinitionSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const definition = await reportsService.createDefinition(dto, tenantId, userId); + + res.status(201).json({ + success: true, + message: 'Definición de reporte creada exitosamente', + data: definition, + }); + } catch (error) { + next(error); + } + } + + // ==================== EXECUTIONS ==================== + + /** + * POST /reports/execute + * Execute a report + */ + async executeReport( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = executeReportSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const execution = await reportsService.executeReport(dto, tenantId, userId); + + res.status(202).json({ + success: true, + message: 'Reporte en ejecución', + data: execution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/executions/:id + * Get execution details and results + */ + async findExecutionById( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + const execution = await reportsService.findExecutionById(id, tenantId); + + res.json({ + success: true, + data: execution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/executions + * Get recent executions + */ + async findRecentExecutions( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { definition_id, limit } = req.query; + const tenantId = req.user!.tenantId; + + const executions = await reportsService.findRecentExecutions( + tenantId, + definition_id as string, + parseInt(limit as string) || 20 + ); + + res.json({ + success: true, + data: executions, + }); + } catch (error) { + next(error); + } + } + + // ==================== SCHEDULES ==================== + + /** + * GET /reports/schedules + * List all schedules + */ + async findAllSchedules( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.user!.tenantId; + const schedules = await reportsService.findAllSchedules(tenantId); + + res.json({ + success: true, + data: schedules, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules + * Create a schedule + */ + async createSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = createScheduleSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const schedule = await reportsService.createSchedule(dto, tenantId, userId); + + res.status(201).json({ + success: true, + message: 'Programación creada exitosamente', + data: schedule, + }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /reports/schedules/:id/toggle + * Enable/disable a schedule + */ + async toggleSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const { is_active } = req.body; + const tenantId = req.user!.tenantId; + + const schedule = await reportsService.toggleSchedule(id, tenantId, is_active); + + res.json({ + success: true, + message: is_active ? 'Programación activada' : 'Programación desactivada', + data: schedule, + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /reports/schedules/:id + * Delete a schedule + */ + async deleteSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + await reportsService.deleteSchedule(id, tenantId); + + res.json({ + success: true, + message: 'Programación eliminada', + }); + } catch (error) { + next(error); + } + } + + // ==================== QUICK REPORTS ==================== + + /** + * GET /reports/quick/trial-balance + * Generate trial balance directly + */ + async getTrialBalance( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const params = trialBalanceSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const data = await reportsService.generateTrialBalance( + tenantId, + params.company_id || null, + params.date_from, + params.date_to, + params.include_zero || false + ); + + // Calculate totals + const totals = { + initial_debit: 0, + initial_credit: 0, + period_debit: 0, + period_credit: 0, + final_debit: 0, + final_credit: 0, + }; + + for (const row of data) { + totals.initial_debit += parseFloat(row.initial_debit) || 0; + totals.initial_credit += parseFloat(row.initial_credit) || 0; + totals.period_debit += parseFloat(row.period_debit) || 0; + totals.period_credit += parseFloat(row.period_credit) || 0; + totals.final_debit += parseFloat(row.final_debit) || 0; + totals.final_credit += parseFloat(row.final_credit) || 0; + } + + res.json({ + success: true, + data, + summary: { + row_count: data.length, + totals, + }, + parameters: params, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/quick/general-ledger + * Generate general ledger directly + */ + async getGeneralLedger( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const params = generalLedgerSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const data = await reportsService.generateGeneralLedger( + tenantId, + params.company_id || null, + params.account_id, + params.date_from, + params.date_to + ); + + // Calculate totals + const totals = { + debit: 0, + credit: 0, + }; + + for (const row of data) { + totals.debit += parseFloat(row.debit) || 0; + totals.credit += parseFloat(row.credit) || 0; + } + + res.json({ + success: true, + data, + summary: { + row_count: data.length, + totals, + final_balance: data.length > 0 ? data[data.length - 1].running_balance : 0, + }, + parameters: params, + }); + } catch (error) { + next(error); + } + } +} + +export const reportsController = new ReportsController(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.routes.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.routes.ts new file mode 100644 index 0000000..fa3c71e --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.routes.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import { reportsController } from './reports.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ============================================================================ +// QUICK REPORTS (direct access without execution record) +// ============================================================================ + +router.get('/quick/trial-balance', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.getTrialBalance(req, res, next) +); + +router.get('/quick/general-ledger', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.getGeneralLedger(req, res, next) +); + +// ============================================================================ +// DEFINITIONS +// ============================================================================ + +// List all report definitions +router.get('/definitions', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findAllDefinitions(req, res, next) +); + +// Get specific definition +router.get('/definitions/:id', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findDefinitionById(req, res, next) +); + +// Create custom definition (admin only) +router.post('/definitions', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.createDefinition(req, res, next) +); + +// ============================================================================ +// EXECUTIONS +// ============================================================================ + +// Execute a report +router.post('/execute', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.executeReport(req, res, next) +); + +// Get recent executions +router.get('/executions', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findRecentExecutions(req, res, next) +); + +// Get specific execution +router.get('/executions/:id', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findExecutionById(req, res, next) +); + +// ============================================================================ +// SCHEDULES +// ============================================================================ + +// List schedules +router.get('/schedules', + requireRoles('admin', 'manager', 'super_admin'), + (req, res, next) => reportsController.findAllSchedules(req, res, next) +); + +// Create schedule +router.post('/schedules', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.createSchedule(req, res, next) +); + +// Toggle schedule +router.patch('/schedules/:id/toggle', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.toggleSchedule(req, res, next) +); + +// Delete schedule +router.delete('/schedules/:id', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.deleteSchedule(req, res, next) +); + +export default router; diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..717af87 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/reports/reports.service.ts @@ -0,0 +1,580 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom'; +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; +export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook'; + +export interface ReportDefinition { + id: string; + tenant_id: string; + code: string; + name: string; + description: string | null; + report_type: ReportType; + category: string | null; + base_query: string | null; + query_function: string | null; + parameters_schema: Record; + columns_config: any[]; + grouping_options: string[]; + totals_config: Record; + export_formats: string[]; + pdf_template: string | null; + xlsx_template: string | null; + is_system: boolean; + is_active: boolean; + required_permissions: string[]; + version: number; + created_at: Date; +} + +export interface ReportExecution { + id: string; + tenant_id: string; + definition_id: string; + definition_name?: string; + definition_code?: string; + parameters: Record; + status: ExecutionStatus; + started_at: Date | null; + completed_at: Date | null; + execution_time_ms: number | null; + row_count: number | null; + result_data: any; + result_summary: Record | null; + output_files: any[]; + error_message: string | null; + error_details: Record | null; + requested_by: string; + requested_by_name?: string; + created_at: Date; +} + +export interface ReportSchedule { + id: string; + tenant_id: string; + definition_id: string; + definition_name?: string; + company_id: string | null; + name: string; + default_parameters: Record; + cron_expression: string; + timezone: string; + is_active: boolean; + last_execution_id: string | null; + last_run_at: Date | null; + next_run_at: Date | null; + delivery_method: DeliveryMethod; + delivery_config: Record; + created_at: Date; +} + +export interface CreateReportDefinitionDto { + code: string; + name: string; + description?: string; + report_type?: ReportType; + category?: string; + base_query?: string; + query_function?: string; + parameters_schema?: Record; + columns_config?: any[]; + export_formats?: string[]; + required_permissions?: string[]; +} + +export interface ExecuteReportDto { + definition_id: string; + parameters: Record; +} + +export interface ReportFilters { + report_type?: ReportType; + category?: string; + is_system?: boolean; + search?: string; + page?: number; + limit?: number; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ReportsService { + // ==================== DEFINITIONS ==================== + + async findAllDefinitions( + tenantId: string, + filters: ReportFilters = {} + ): Promise<{ data: ReportDefinition[]; total: number }> { + const { report_type, category, is_system, search, page = 1, limit = 20 } = filters; + const conditions: string[] = ['tenant_id = $1', 'is_active = true']; + const params: any[] = [tenantId]; + let idx = 2; + + if (report_type) { + conditions.push(`report_type = $${idx++}`); + params.push(report_type); + } + + if (category) { + conditions.push(`category = $${idx++}`); + params.push(category); + } + + if (is_system !== undefined) { + conditions.push(`is_system = $${idx++}`); + params.push(is_system); + } + + if (search) { + conditions.push(`(name ILIKE $${idx} OR code ILIKE $${idx} OR description ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const whereClause = conditions.join(' AND '); + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM reports.report_definitions WHERE ${whereClause}`, + params + ); + + const offset = (page - 1) * limit; + params.push(limit, offset); + + const data = await query( + `SELECT * FROM reports.report_definitions + WHERE ${whereClause} + ORDER BY is_system DESC, name ASC + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findDefinitionById(id: string, tenantId: string): Promise { + const definition = await queryOne( + `SELECT * FROM reports.report_definitions WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!definition) { + throw new NotFoundError('Definición de reporte no encontrada'); + } + + return definition; + } + + async findDefinitionByCode(code: string, tenantId: string): Promise { + return queryOne( + `SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`, + [code, tenantId] + ); + } + + async createDefinition( + dto: CreateReportDefinitionDto, + tenantId: string, + userId: string + ): Promise { + const definition = await queryOne( + `INSERT INTO reports.report_definitions ( + tenant_id, code, name, description, report_type, category, + base_query, query_function, parameters_schema, columns_config, + export_formats, required_permissions, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + tenantId, + dto.code, + dto.name, + dto.description || null, + dto.report_type || 'custom', + dto.category || null, + dto.base_query || null, + dto.query_function || null, + JSON.stringify(dto.parameters_schema || {}), + JSON.stringify(dto.columns_config || []), + JSON.stringify(dto.export_formats || ['pdf', 'xlsx', 'csv']), + JSON.stringify(dto.required_permissions || []), + userId, + ] + ); + + logger.info('Report definition created', { definitionId: definition?.id, code: dto.code }); + + return definition!; + } + + // ==================== EXECUTIONS ==================== + + async executeReport( + dto: ExecuteReportDto, + tenantId: string, + userId: string + ): Promise { + const definition = await this.findDefinitionById(dto.definition_id, tenantId); + + // Validar parámetros contra el schema + this.validateParameters(dto.parameters, definition.parameters_schema); + + // Crear registro de ejecución + const execution = await queryOne( + `INSERT INTO reports.report_executions ( + tenant_id, definition_id, parameters, status, requested_by + ) VALUES ($1, $2, $3, 'pending', $4) + RETURNING *`, + [tenantId, dto.definition_id, JSON.stringify(dto.parameters), userId] + ); + + // Ejecutar el reporte de forma asíncrona + this.runReportExecution(execution!.id, definition, dto.parameters, tenantId) + .catch(err => logger.error('Report execution failed', { executionId: execution!.id, error: err })); + + return execution!; + } + + private async runReportExecution( + executionId: string, + definition: ReportDefinition, + parameters: Record, + tenantId: string + ): Promise { + const startTime = Date.now(); + + try { + // Marcar como ejecutando + await query( + `UPDATE reports.report_executions SET status = 'running', started_at = NOW() WHERE id = $1`, + [executionId] + ); + + let resultData: any; + let rowCount = 0; + + if (definition.query_function) { + // Ejecutar función PostgreSQL + const funcParams = this.buildFunctionParams(definition.query_function, parameters, tenantId); + resultData = await query( + `SELECT * FROM ${definition.query_function}(${funcParams.placeholders})`, + funcParams.values + ); + rowCount = resultData.length; + } else if (definition.base_query) { + // Ejecutar query base con parámetros sustituidos + // IMPORTANTE: Sanitizar los parámetros para evitar SQL injection + const sanitizedQuery = this.buildSafeQuery(definition.base_query, parameters, tenantId); + resultData = await query(sanitizedQuery.sql, sanitizedQuery.values); + rowCount = resultData.length; + } else { + throw new Error('La definición del reporte no tiene query ni función definida'); + } + + const executionTime = Date.now() - startTime; + + // Calcular resumen si hay config de totales + const resultSummary = this.calculateSummary(resultData, definition.totals_config); + + // Actualizar con resultados + await query( + `UPDATE reports.report_executions + SET status = 'completed', + completed_at = NOW(), + execution_time_ms = $2, + row_count = $3, + result_data = $4, + result_summary = $5 + WHERE id = $1`, + [executionId, executionTime, rowCount, JSON.stringify(resultData), JSON.stringify(resultSummary)] + ); + + logger.info('Report execution completed', { executionId, rowCount, executionTime }); + + } catch (error: any) { + const executionTime = Date.now() - startTime; + + await query( + `UPDATE reports.report_executions + SET status = 'failed', + completed_at = NOW(), + execution_time_ms = $2, + error_message = $3, + error_details = $4 + WHERE id = $1`, + [ + executionId, + executionTime, + error.message, + JSON.stringify({ stack: error.stack }), + ] + ); + + logger.error('Report execution failed', { executionId, error: error.message }); + } + } + + private buildFunctionParams( + functionName: string, + parameters: Record, + tenantId: string + ): { placeholders: string; values: any[] } { + // Construir parámetros para funciones conocidas + const values: any[] = [tenantId]; + let idx = 2; + + if (functionName.includes('trial_balance')) { + values.push( + parameters.company_id || null, + parameters.date_from, + parameters.date_to, + parameters.include_zero || false + ); + return { placeholders: '$1, $2, $3, $4, $5', values }; + } + + if (functionName.includes('general_ledger')) { + values.push( + parameters.company_id || null, + parameters.account_id, + parameters.date_from, + parameters.date_to + ); + return { placeholders: '$1, $2, $3, $4, $5', values }; + } + + // Default: solo tenant_id + return { placeholders: '$1', values }; + } + + private buildSafeQuery( + baseQuery: string, + parameters: Record, + tenantId: string + ): { sql: string; values: any[] } { + // Reemplazar placeholders de forma segura + let sql = baseQuery; + const values: any[] = [tenantId]; + let idx = 2; + + // Reemplazar {{tenant_id}} con $1 + sql = sql.replace(/\{\{tenant_id\}\}/g, '$1'); + + // Reemplazar otros parámetros + for (const [key, value] of Object.entries(parameters)) { + const placeholder = `{{${key}}}`; + if (sql.includes(placeholder)) { + sql = sql.replace(new RegExp(placeholder, 'g'), `$${idx}`); + values.push(value); + idx++; + } + } + + return { sql, values }; + } + + private calculateSummary(data: any[], totalsConfig: Record): Record { + if (!totalsConfig.show_totals || !totalsConfig.total_columns) { + return {}; + } + + const summary: Record = {}; + + for (const column of totalsConfig.total_columns) { + summary[column] = data.reduce((sum, row) => sum + (parseFloat(row[column]) || 0), 0); + } + + return summary; + } + + private validateParameters(params: Record, schema: Record): void { + for (const [key, config] of Object.entries(schema)) { + const paramConfig = config as { required?: boolean; type?: string }; + + if (paramConfig.required && (params[key] === undefined || params[key] === null)) { + throw new ValidationError(`Parámetro requerido: ${key}`); + } + } + } + + async findExecutionById(id: string, tenantId: string): Promise { + const execution = await queryOne( + `SELECT re.*, + rd.name as definition_name, + rd.code as definition_code, + u.full_name as requested_by_name + FROM reports.report_executions re + JOIN reports.report_definitions rd ON re.definition_id = rd.id + JOIN auth.users u ON re.requested_by = u.id + WHERE re.id = $1 AND re.tenant_id = $2`, + [id, tenantId] + ); + + if (!execution) { + throw new NotFoundError('Ejecución de reporte no encontrada'); + } + + return execution; + } + + async findRecentExecutions( + tenantId: string, + definitionId?: string, + limit: number = 20 + ): Promise { + let sql = ` + SELECT re.*, + rd.name as definition_name, + rd.code as definition_code, + u.full_name as requested_by_name + FROM reports.report_executions re + JOIN reports.report_definitions rd ON re.definition_id = rd.id + JOIN auth.users u ON re.requested_by = u.id + WHERE re.tenant_id = $1 + `; + const params: any[] = [tenantId]; + + if (definitionId) { + sql += ` AND re.definition_id = $2`; + params.push(definitionId); + } + + sql += ` ORDER BY re.created_at DESC LIMIT $${params.length + 1}`; + params.push(limit); + + return query(sql, params); + } + + // ==================== SCHEDULES ==================== + + async findAllSchedules(tenantId: string): Promise { + return query( + `SELECT rs.*, + rd.name as definition_name + FROM reports.report_schedules rs + JOIN reports.report_definitions rd ON rs.definition_id = rd.id + WHERE rs.tenant_id = $1 + ORDER BY rs.name`, + [tenantId] + ); + } + + async createSchedule( + data: { + definition_id: string; + name: string; + cron_expression: string; + default_parameters?: Record; + company_id?: string; + timezone?: string; + delivery_method?: DeliveryMethod; + delivery_config?: Record; + }, + tenantId: string, + userId: string + ): Promise { + // Verificar que la definición existe + await this.findDefinitionById(data.definition_id, tenantId); + + const schedule = await queryOne( + `INSERT INTO reports.report_schedules ( + tenant_id, definition_id, name, cron_expression, + default_parameters, company_id, timezone, + delivery_method, delivery_config, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, + data.definition_id, + data.name, + data.cron_expression, + JSON.stringify(data.default_parameters || {}), + data.company_id || null, + data.timezone || 'America/Mexico_City', + data.delivery_method || 'none', + JSON.stringify(data.delivery_config || {}), + userId, + ] + ); + + logger.info('Report schedule created', { scheduleId: schedule?.id, name: data.name }); + + return schedule!; + } + + async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise { + const schedule = await queryOne( + `UPDATE reports.report_schedules + SET is_active = $3, updated_at = NOW() + WHERE id = $1 AND tenant_id = $2 + RETURNING *`, + [id, tenantId, isActive] + ); + + if (!schedule) { + throw new NotFoundError('Programación no encontrada'); + } + + return schedule; + } + + async deleteSchedule(id: string, tenantId: string): Promise { + const result = await query( + `DELETE FROM reports.report_schedules WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + // Check if any row was deleted + if (!result || result.length === 0) { + // Try to verify it existed + const exists = await queryOne<{ id: string }>( + `SELECT id FROM reports.report_schedules WHERE id = $1`, + [id] + ); + if (!exists) { + throw new NotFoundError('Programación no encontrada'); + } + } + } + + // ==================== QUICK REPORTS ==================== + + async generateTrialBalance( + tenantId: string, + companyId: string | null, + dateFrom: string, + dateTo: string, + includeZero: boolean = false + ): Promise { + return query( + `SELECT * FROM reports.generate_trial_balance($1, $2, $3, $4, $5)`, + [tenantId, companyId, dateFrom, dateTo, includeZero] + ); + } + + async generateGeneralLedger( + tenantId: string, + companyId: string | null, + accountId: string, + dateFrom: string, + dateTo: string + ): Promise { + return query( + `SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`, + [tenantId, companyId, accountId, dateFrom, dateTo] + ); + } +} + +export const reportsService = new ReportsService(); diff --git a/projects/erp-suite/apps/erp-core/backend/src/modules/sales/orders.service.ts b/projects/erp-suite/apps/erp-core/backend/src/modules/sales/orders.service.ts index d4b0e6c..cca04fc 100644 --- a/projects/erp-suite/apps/erp-core/backend/src/modules/sales/orders.service.ts +++ b/projects/erp-suite/apps/erp-core/backend/src/modules/sales/orders.service.ts @@ -1,6 +1,7 @@ import { query, queryOne, getClient } from '../../config/database.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; import { taxesService } from '../financial/taxes.service.js'; +import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; export interface SalesOrderLine { id: string; @@ -252,13 +253,8 @@ class OrdersService { } async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise { - // Generate sequence number - const seqResult = await queryOne<{ next_num: number }>( - `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 4) AS INTEGER)), 0) + 1 as next_num - FROM sales.sales_orders WHERE tenant_id = $1 AND name LIKE 'SO-%'`, - [tenantId] - ); - const orderNumber = `SO-${String(seqResult?.next_num || 1).padStart(6, '0')}`; + // Generate sequence number using atomic database function + const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId); const orderDate = dto.order_date || new Date().toISOString().split('T')[0]; diff --git a/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/apiKeyAuth.middleware.ts b/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/apiKeyAuth.middleware.ts new file mode 100644 index 0000000..db513da --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/apiKeyAuth.middleware.ts @@ -0,0 +1,217 @@ +import { Response, NextFunction } from 'express'; +import { apiKeysService } from '../../modules/auth/apiKeys.service.js'; +import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// API KEY AUTHENTICATION MIDDLEWARE +// ============================================================================ + +/** + * Header name for API Key authentication + * Supports both X-API-Key and Authorization: ApiKey xxx + */ +const API_KEY_HEADER = 'x-api-key'; +const API_KEY_AUTH_PREFIX = 'ApiKey '; + +/** + * Extract API key from request headers + */ +function extractApiKey(req: AuthenticatedRequest): string | null { + // Check X-API-Key header first + const xApiKey = req.headers[API_KEY_HEADER] as string; + if (xApiKey) { + return xApiKey; + } + + // Check Authorization header with ApiKey prefix + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith(API_KEY_AUTH_PREFIX)) { + return authHeader.substring(API_KEY_AUTH_PREFIX.length); + } + + return null; +} + +/** + * Get client IP address from request + */ +function getClientIp(req: AuthenticatedRequest): string | undefined { + // Check X-Forwarded-For header (for proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + const ips = (forwardedFor as string).split(','); + return ips[0].trim(); + } + + // Check X-Real-IP header + const realIp = req.headers['x-real-ip'] as string; + if (realIp) { + return realIp; + } + + // Fallback to socket remote address + return req.socket.remoteAddress; +} + +/** + * Authenticate request using API Key + * Use this middleware for API endpoints that should accept API Key authentication + */ +export function authenticateApiKey( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + (async () => { + try { + const apiKey = extractApiKey(req); + + if (!apiKey) { + throw new UnauthorizedError('API key requerida'); + } + + const clientIp = getClientIp(req); + const result = await apiKeysService.validate(apiKey, clientIp); + + if (!result.valid || !result.user) { + logger.warn('API key validation failed', { + error: result.error, + clientIp, + }); + throw new UnauthorizedError(result.error || 'API key inválida'); + } + + // Set user info on request (same format as JWT auth) + req.user = { + userId: result.user.id, + tenantId: result.user.tenant_id, + email: result.user.email, + roles: result.user.roles, + }; + req.tenantId = result.user.tenant_id; + + // Mark request as authenticated via API Key (for logging/audit) + (req as any).authMethod = 'api_key'; + (req as any).apiKeyId = result.apiKey?.id; + + next(); + } catch (error) { + next(error); + } + })(); +} + +/** + * Authenticate request using either JWT or API Key + * Use this for endpoints that should accept both authentication methods + */ +export function authenticateJwtOrApiKey( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const apiKey = extractApiKey(req); + const jwtToken = req.headers.authorization?.startsWith('Bearer '); + + if (apiKey) { + // Use API Key authentication + authenticateApiKey(req, res, next); + } else if (jwtToken) { + // Use JWT authentication - import dynamically to avoid circular deps + import('./auth.middleware.js').then(({ authenticate }) => { + authenticate(req, res, next); + }); + } else { + next(new UnauthorizedError('Autenticación requerida (JWT o API Key)')); + } +} + +/** + * Require specific API key scope + * Use after authenticateApiKey to enforce scope restrictions + */ +export function requireApiKeyScope(requiredScope: string) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + const authMethod = (req as any).authMethod; + + // Only check scope for API Key auth + if (authMethod !== 'api_key') { + return next(); + } + + // Get API key scope from database (cached in validation result) + // For now, we'll re-validate - in production, cache this + (async () => { + const apiKey = extractApiKey(req); + if (!apiKey) { + throw new ForbiddenError('API key no encontrada'); + } + + const result = await apiKeysService.validate(apiKey); + if (!result.valid || !result.apiKey) { + throw new ForbiddenError('API key inválida'); + } + + // Null scope means full access + if (result.apiKey.scope === null) { + return next(); + } + + // Check if scope matches + if (result.apiKey.scope !== requiredScope) { + logger.warn('API key scope mismatch', { + apiKeyId, + requiredScope, + actualScope: result.apiKey.scope, + }); + throw new ForbiddenError(`API key no tiene el scope requerido: ${requiredScope}`); + } + + next(); + })(); + } catch (error) { + next(error); + } + }; +} + +/** + * Rate limiting for API Key requests + * Simple in-memory rate limiter - use Redis in production + */ +const rateLimitStore = new Map(); + +export function apiKeyRateLimit(maxRequests: number = 1000, windowMs: number = 60000) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + if (!apiKeyId) { + return next(); + } + + const now = Date.now(); + const record = rateLimitStore.get(apiKeyId); + + if (!record || now > record.resetTime) { + rateLimitStore.set(apiKeyId, { + count: 1, + resetTime: now + windowMs, + }); + return next(); + } + + if (record.count >= maxRequests) { + logger.warn('API key rate limit exceeded', { apiKeyId, count: record.count }); + throw new ForbiddenError('Rate limit excedido. Intente más tarde.'); + } + + record.count++; + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/fieldPermissions.middleware.ts b/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/fieldPermissions.middleware.ts new file mode 100644 index 0000000..1658168 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/backend/src/shared/middleware/fieldPermissions.middleware.ts @@ -0,0 +1,343 @@ +import { Response, NextFunction } from 'express'; +import { query, queryOne } from '../../config/database.js'; +import { AuthenticatedRequest } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface FieldPermission { + field_name: string; + can_read: boolean; + can_write: boolean; +} + +export interface ModelFieldPermissions { + model_name: string; + fields: Map; +} + +// Cache for field permissions per user/model +const permissionsCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Get cache key for user/model combination + */ +function getCacheKey(userId: string, tenantId: string, modelName: string): string { + return `${tenantId}:${userId}:${modelName}`; +} + +/** + * Load field permissions for a user on a specific model + */ +async function loadFieldPermissions( + userId: string, + tenantId: string, + modelName: string +): Promise { + // Check cache first + const cacheKey = getCacheKey(userId, tenantId, modelName); + const cached = permissionsCache.get(cacheKey); + + if (cached && cached.expires > Date.now()) { + return cached.permissions; + } + + // Load from database + const result = await query<{ + field_name: string; + can_read: boolean; + can_write: boolean; + }>( + `SELECT + mf.name as field_name, + COALESCE(fp.can_read, true) as can_read, + COALESCE(fp.can_write, true) as can_write + FROM auth.model_fields mf + JOIN auth.models m ON mf.model_id = m.id + LEFT JOIN auth.field_permissions fp ON mf.id = fp.field_id + LEFT JOIN auth.user_groups ug ON fp.group_id = ug.group_id + WHERE m.model = $1 + AND m.tenant_id = $2 + AND (ug.user_id = $3 OR fp.group_id IS NULL) + GROUP BY mf.name, fp.can_read, fp.can_write`, + [modelName, tenantId, userId] + ); + + if (result.length === 0) { + // No permissions defined = allow all + return null; + } + + const permissions: ModelFieldPermissions = { + model_name: modelName, + fields: new Map(), + }; + + for (const row of result) { + permissions.fields.set(row.field_name, { + field_name: row.field_name, + can_read: row.can_read, + can_write: row.can_write, + }); + } + + // Cache the result + permissionsCache.set(cacheKey, { + permissions, + expires: Date.now() + CACHE_TTL, + }); + + return permissions; +} + +/** + * Filter object fields based on read permissions + */ +function filterReadFields>( + data: T, + permissions: ModelFieldPermissions | null +): Partial { + // No permissions defined = return all fields + if (!permissions || permissions.fields.size === 0) { + return data; + } + + const filtered: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const fieldPerm = permissions.fields.get(key); + + // If no permission defined for field, allow it + // If permission exists and can_read is true, allow it + if (!fieldPerm || fieldPerm.can_read) { + filtered[key] = value; + } + } + + return filtered as Partial; +} + +/** + * Filter array of objects + */ +function filterReadFieldsArray>( + data: T[], + permissions: ModelFieldPermissions | null +): Partial[] { + return data.map(item => filterReadFields(item, permissions)); +} + +/** + * Validate write permissions for incoming data + */ +function validateWriteFields>( + data: T, + permissions: ModelFieldPermissions | null +): { valid: boolean; forbiddenFields: string[] } { + // No permissions defined = allow all writes + if (!permissions || permissions.fields.size === 0) { + return { valid: true, forbiddenFields: [] }; + } + + const forbiddenFields: string[] = []; + + for (const key of Object.keys(data)) { + const fieldPerm = permissions.fields.get(key); + + // If permission exists and can_write is false, it's forbidden + if (fieldPerm && !fieldPerm.can_write) { + forbiddenFields.push(key); + } + } + + return { + valid: forbiddenFields.length === 0, + forbiddenFields, + }; +} + +// ============================================================================ +// MIDDLEWARE FACTORIES +// ============================================================================ + +/** + * Middleware to filter response fields based on read permissions + * Use this on GET endpoints + */ +export function filterResponseFields(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // Store original json method + const originalJson = res.json.bind(res); + + // Override json method to filter fields + res.json = function(body: any) { + (async () => { + try { + // Only filter for authenticated requests + if (!req.user) { + return originalJson(body); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // If no permissions defined or super_admin, return original + if (!permissions || req.user.roles.includes('super_admin')) { + return originalJson(body); + } + + // Filter the response + if (body && typeof body === 'object') { + if (body.data) { + if (Array.isArray(body.data)) { + body.data = filterReadFieldsArray(body.data, permissions); + } else if (typeof body.data === 'object') { + body.data = filterReadFields(body.data, permissions); + } + } else if (Array.isArray(body)) { + body = filterReadFieldsArray(body, permissions); + } + } + + return originalJson(body); + } catch (error) { + logger.error('Error filtering response fields', { error, modelName }); + return originalJson(body); + } + })(); + } as typeof res.json; + + next(); + }; +} + +/** + * Middleware to validate write permissions on incoming data + * Use this on POST/PUT/PATCH endpoints + */ +export function validateWritePermissions(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + try { + // Skip for unauthenticated requests (they'll fail auth anyway) + if (!req.user) { + return next(); + } + + // Super admins bypass field permission checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // No permissions defined = allow all + if (!permissions) { + return next(); + } + + // Validate write fields in request body + if (req.body && typeof req.body === 'object') { + const { valid, forbiddenFields } = validateWriteFields(req.body, permissions); + + if (!valid) { + logger.warn('Write permission denied for fields', { + userId: req.user.userId, + modelName, + forbiddenFields, + }); + + res.status(403).json({ + success: false, + error: `No tiene permisos para modificar los campos: ${forbiddenFields.join(', ')}`, + forbiddenFields, + }); + return; + } + } + + next(); + } catch (error) { + logger.error('Error validating write permissions', { error, modelName }); + next(error); + } + }; +} + +/** + * Combined middleware for both read and write validation + */ +export function fieldPermissions(modelName: string) { + const readFilter = filterResponseFields(modelName); + const writeValidator = validateWritePermissions(modelName); + + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // For write operations, validate first + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + await writeValidator(req, res, () => { + // If write validation passed, apply read filter for response + readFilter(req, res, next); + }); + } else { + // For read operations, just apply read filter + await readFilter(req, res, next); + } + }; +} + +/** + * Clear permissions cache for a user (call after permission changes) + */ +export function clearPermissionsCache(userId?: string, tenantId?: string): void { + if (userId && tenantId) { + // Clear specific user's cache + const prefix = `${tenantId}:${userId}:`; + for (const key of permissionsCache.keys()) { + if (key.startsWith(prefix)) { + permissionsCache.delete(key); + } + } + } else { + // Clear all cache + permissionsCache.clear(); + } +} + +/** + * Get list of restricted fields for a user on a model + * Useful for frontend to know which fields to hide/disable + */ +export async function getRestrictedFields( + userId: string, + tenantId: string, + modelName: string +): Promise<{ readRestricted: string[]; writeRestricted: string[] }> { + const permissions = await loadFieldPermissions(userId, tenantId, modelName); + + const readRestricted: string[] = []; + const writeRestricted: string[] = []; + + if (permissions) { + for (const [fieldName, perm] of permissions.fields) { + if (!perm.can_read) readRestricted.push(fieldName); + if (!perm.can_write) writeRestricted.push(fieldName); + } + } + + return { readRestricted, writeRestricted }; +} diff --git a/projects/erp-suite/apps/erp-core/database/migrations/20251212_001_fiscal_period_validation.sql b/projects/erp-suite/apps/erp-core/database/migrations/20251212_001_fiscal_period_validation.sql new file mode 100644 index 0000000..48841be --- /dev/null +++ b/projects/erp-suite/apps/erp-core/database/migrations/20251212_001_fiscal_period_validation.sql @@ -0,0 +1,207 @@ +-- ============================================================================ +-- MIGRACIÓN: Validación de Período Fiscal Cerrado +-- Fecha: 2025-12-12 +-- Descripción: Agrega trigger para prevenir asientos en períodos cerrados +-- Impacto: Todas las verticales que usan el módulo financiero +-- Rollback: DROP TRIGGER y DROP FUNCTION incluidos al final +-- ============================================================================ + +-- ============================================================================ +-- 1. FUNCIÓN DE VALIDACIÓN +-- ============================================================================ + +CREATE OR REPLACE FUNCTION financial.validate_period_not_closed() +RETURNS TRIGGER AS $$ +DECLARE + v_period_status TEXT; + v_period_name TEXT; +BEGIN + -- Solo validar si hay un fiscal_period_id + IF NEW.fiscal_period_id IS NULL THEN + RETURN NEW; + END IF; + + -- Obtener el estado del período + SELECT fp.status, fp.name INTO v_period_status, v_period_name + FROM financial.fiscal_periods fp + WHERE fp.id = NEW.fiscal_period_id; + + -- Validar que el período no esté cerrado + IF v_period_status = 'closed' THEN + RAISE EXCEPTION 'ERR_PERIOD_CLOSED: No se pueden crear o modificar asientos en el período cerrado: %', v_period_name + USING ERRCODE = 'P0001'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION financial.validate_period_not_closed() IS +'Valida que no se creen asientos contables en períodos fiscales cerrados. +Lanza excepción ERR_PERIOD_CLOSED si el período está cerrado.'; + +-- ============================================================================ +-- 2. TRIGGER EN JOURNAL_ENTRIES +-- ============================================================================ + +-- Eliminar trigger si existe (idempotente) +DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries; + +-- Crear trigger BEFORE INSERT OR UPDATE +CREATE TRIGGER trg_validate_period_before_entry + BEFORE INSERT OR UPDATE ON financial.journal_entries + FOR EACH ROW + EXECUTE FUNCTION financial.validate_period_not_closed(); + +COMMENT ON TRIGGER trg_validate_period_before_entry ON financial.journal_entries IS +'Previene la creación o modificación de asientos en períodos fiscales cerrados'; + +-- ============================================================================ +-- 3. FUNCIÓN PARA CERRAR PERÍODO +-- ============================================================================ + +CREATE OR REPLACE FUNCTION financial.close_fiscal_period( + p_period_id UUID, + p_user_id UUID +) +RETURNS financial.fiscal_periods AS $$ +DECLARE + v_period financial.fiscal_periods; + v_unposted_count INTEGER; +BEGIN + -- Obtener período + SELECT * INTO v_period + FROM financial.fiscal_periods + WHERE id = p_period_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002'; + END IF; + + IF v_period.status = 'closed' THEN + RAISE EXCEPTION 'El período ya está cerrado' USING ERRCODE = 'P0003'; + END IF; + + -- Verificar que no haya asientos sin postear + SELECT COUNT(*) INTO v_unposted_count + FROM financial.journal_entries je + WHERE je.fiscal_period_id = p_period_id + AND je.status = 'draft'; + + IF v_unposted_count > 0 THEN + RAISE EXCEPTION 'Existen % asientos sin postear en este período. Postéelos antes de cerrar.', + v_unposted_count USING ERRCODE = 'P0004'; + END IF; + + -- Cerrar el período + UPDATE financial.fiscal_periods + SET status = 'closed', + closed_at = NOW(), + closed_by = p_user_id, + updated_at = NOW() + WHERE id = p_period_id + RETURNING * INTO v_period; + + RETURN v_period; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION financial.close_fiscal_period(UUID, UUID) IS +'Cierra un período fiscal. Valida que todos los asientos estén posteados.'; + +-- ============================================================================ +-- 4. FUNCIÓN PARA REABRIR PERÍODO (Solo admins) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION financial.reopen_fiscal_period( + p_period_id UUID, + p_user_id UUID, + p_reason TEXT DEFAULT NULL +) +RETURNS financial.fiscal_periods AS $$ +DECLARE + v_period financial.fiscal_periods; +BEGIN + -- Obtener período + SELECT * INTO v_period + FROM financial.fiscal_periods + WHERE id = p_period_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002'; + END IF; + + IF v_period.status = 'open' THEN + RAISE EXCEPTION 'El período ya está abierto' USING ERRCODE = 'P0005'; + END IF; + + -- Reabrir el período + UPDATE financial.fiscal_periods + SET status = 'open', + closed_at = NULL, + closed_by = NULL, + updated_at = NOW() + WHERE id = p_period_id + RETURNING * INTO v_period; + + -- Registrar en log de auditoría + INSERT INTO system.logs ( + tenant_id, level, module, message, context, user_id + ) + SELECT + v_period.tenant_id, + 'warning', + 'financial', + 'Período fiscal reabierto', + jsonb_build_object( + 'period_id', p_period_id, + 'period_name', v_period.name, + 'reason', p_reason, + 'reopened_by', p_user_id + ), + p_user_id; + + RETURN v_period; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION financial.reopen_fiscal_period(UUID, UUID, TEXT) IS +'Reabre un período fiscal cerrado. Registra en auditoría. Solo para administradores.'; + +-- ============================================================================ +-- 5. ÍNDICE PARA PERFORMANCE +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_journal_entries_fiscal_period + ON financial.journal_entries(fiscal_period_id) + WHERE fiscal_period_id IS NOT NULL; + +-- ============================================================================ +-- ROLLBACK SCRIPT (ejecutar si es necesario revertir) +-- ============================================================================ +/* +DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries; +DROP FUNCTION IF EXISTS financial.validate_period_not_closed(); +DROP FUNCTION IF EXISTS financial.close_fiscal_period(UUID, UUID); +DROP FUNCTION IF EXISTS financial.reopen_fiscal_period(UUID, UUID, TEXT); +DROP INDEX IF EXISTS financial.idx_journal_entries_fiscal_period; +*/ + +-- ============================================================================ +-- VERIFICACIÓN +-- ============================================================================ + +DO $$ +BEGIN + -- Verificar que el trigger existe + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgname = 'trg_validate_period_before_entry' + ) THEN + RAISE EXCEPTION 'Error: Trigger no fue creado correctamente'; + END IF; + + RAISE NOTICE 'Migración completada exitosamente: Validación de período fiscal'; +END $$; diff --git a/projects/erp-suite/apps/erp-core/database/migrations/20251212_002_partner_rankings.sql b/projects/erp-suite/apps/erp-core/database/migrations/20251212_002_partner_rankings.sql new file mode 100644 index 0000000..7f0cbe5 --- /dev/null +++ b/projects/erp-suite/apps/erp-core/database/migrations/20251212_002_partner_rankings.sql @@ -0,0 +1,391 @@ +-- ============================================================================ +-- MIGRACIÓN: Sistema de Ranking de Partners (Clientes/Proveedores) +-- Fecha: 2025-12-12 +-- Descripción: Crea tablas y funciones para clasificación ABC de partners +-- Impacto: Verticales que usan módulo de partners/ventas/compras +-- Rollback: DROP TABLE y DROP FUNCTION incluidos al final +-- ============================================================================ + +-- ============================================================================ +-- 1. TABLA DE RANKINGS POR PERÍODO +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS core.partner_rankings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, + company_id UUID REFERENCES auth.companies(id) ON DELETE SET NULL, + + -- Período de análisis + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Métricas de Cliente + total_sales DECIMAL(16,2) DEFAULT 0, + sales_order_count INTEGER DEFAULT 0, + avg_order_value DECIMAL(16,2) DEFAULT 0, + + -- Métricas de Proveedor + total_purchases DECIMAL(16,2) DEFAULT 0, + purchase_order_count INTEGER DEFAULT 0, + avg_purchase_value DECIMAL(16,2) DEFAULT 0, + + -- Métricas de Pago + avg_payment_days INTEGER, + on_time_payment_rate DECIMAL(5,2), -- Porcentaje 0-100 + + -- Rankings (posición relativa dentro del período) + sales_rank INTEGER, + purchase_rank INTEGER, + + -- Clasificación ABC + customer_abc CHAR(1) CHECK (customer_abc IN ('A', 'B', 'C', NULL)), + supplier_abc CHAR(1) CHECK (supplier_abc IN ('A', 'B', 'C', NULL)), + + -- Scores calculados (0-100) + customer_score DECIMAL(5,2) CHECK (customer_score IS NULL OR customer_score BETWEEN 0 AND 100), + supplier_score DECIMAL(5,2) CHECK (supplier_score IS NULL OR supplier_score BETWEEN 0 AND 100), + overall_score DECIMAL(5,2) CHECK (overall_score IS NULL OR overall_score BETWEEN 0 AND 100), + + -- Tendencia vs período anterior + sales_trend DECIMAL(5,2), -- % cambio + purchase_trend DECIMAL(5,2), + + -- Metadatos + calculated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- Constraints + UNIQUE(tenant_id, partner_id, company_id, period_start, period_end), + CHECK (period_end >= period_start) +); + +-- ============================================================================ +-- 2. CAMPOS DESNORMALIZADOS EN PARTNERS (para consultas rápidas) +-- ============================================================================ + +DO $$ +BEGIN + -- Agregar columnas si no existen + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'customer_rank') THEN + ALTER TABLE core.partners ADD COLUMN customer_rank INTEGER; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'supplier_rank') THEN + ALTER TABLE core.partners ADD COLUMN supplier_rank INTEGER; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'customer_abc') THEN + ALTER TABLE core.partners ADD COLUMN customer_abc CHAR(1); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'supplier_abc') THEN + ALTER TABLE core.partners ADD COLUMN supplier_abc CHAR(1); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'last_ranking_date') THEN + ALTER TABLE core.partners ADD COLUMN last_ranking_date DATE; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'total_sales_ytd') THEN + ALTER TABLE core.partners ADD COLUMN total_sales_ytd DECIMAL(16,2) DEFAULT 0; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'core' AND table_name = 'partners' + AND column_name = 'total_purchases_ytd') THEN + ALTER TABLE core.partners ADD COLUMN total_purchases_ytd DECIMAL(16,2) DEFAULT 0; + END IF; +END $$; + +-- ============================================================================ +-- 3. ÍNDICES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_partner_rankings_tenant_period + ON core.partner_rankings(tenant_id, period_start, period_end); + +CREATE INDEX IF NOT EXISTS idx_partner_rankings_partner + ON core.partner_rankings(partner_id); + +CREATE INDEX IF NOT EXISTS idx_partner_rankings_abc + ON core.partner_rankings(tenant_id, customer_abc) + WHERE customer_abc IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_partners_customer_rank + ON core.partners(tenant_id, customer_rank) + WHERE customer_rank IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_partners_supplier_rank + ON core.partners(tenant_id, supplier_rank) + WHERE supplier_rank IS NOT NULL; + +-- ============================================================================ +-- 4. RLS (Row Level Security) +-- ============================================================================ + +ALTER TABLE core.partner_rankings ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS partner_rankings_tenant_isolation ON core.partner_rankings; +CREATE POLICY partner_rankings_tenant_isolation ON core.partner_rankings + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- 5. FUNCIÓN: Calcular rankings de partners +-- ============================================================================ + +CREATE OR REPLACE FUNCTION core.calculate_partner_rankings( + p_tenant_id UUID, + p_company_id UUID DEFAULT NULL, + p_period_start DATE DEFAULT (CURRENT_DATE - INTERVAL '1 year')::date, + p_period_end DATE DEFAULT CURRENT_DATE +) +RETURNS TABLE ( + partners_processed INTEGER, + customers_ranked INTEGER, + suppliers_ranked INTEGER +) AS $$ +DECLARE + v_partners_processed INTEGER := 0; + v_customers_ranked INTEGER := 0; + v_suppliers_ranked INTEGER := 0; +BEGIN + -- 1. Calcular métricas de ventas por partner + INSERT INTO core.partner_rankings ( + tenant_id, partner_id, company_id, period_start, period_end, + total_sales, sales_order_count, avg_order_value + ) + SELECT + p_tenant_id, + so.partner_id, + COALESCE(p_company_id, so.company_id), + p_period_start, + p_period_end, + COALESCE(SUM(so.amount_total), 0), + COUNT(*), + COALESCE(AVG(so.amount_total), 0) + FROM sales.sales_orders so + WHERE so.tenant_id = p_tenant_id + AND so.status IN ('sale', 'done') + AND so.order_date BETWEEN p_period_start AND p_period_end + AND (p_company_id IS NULL OR so.company_id = p_company_id) + GROUP BY so.partner_id, so.company_id + ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) + DO UPDATE SET + total_sales = EXCLUDED.total_sales, + sales_order_count = EXCLUDED.sales_order_count, + avg_order_value = EXCLUDED.avg_order_value, + calculated_at = NOW(); + + GET DIAGNOSTICS v_customers_ranked = ROW_COUNT; + + -- 2. Calcular métricas de compras por partner + INSERT INTO core.partner_rankings ( + tenant_id, partner_id, company_id, period_start, period_end, + total_purchases, purchase_order_count, avg_purchase_value + ) + SELECT + p_tenant_id, + po.partner_id, + COALESCE(p_company_id, po.company_id), + p_period_start, + p_period_end, + COALESCE(SUM(po.amount_total), 0), + COUNT(*), + COALESCE(AVG(po.amount_total), 0) + FROM purchase.purchase_orders po + WHERE po.tenant_id = p_tenant_id + AND po.status IN ('confirmed', 'done') + AND po.order_date BETWEEN p_period_start AND p_period_end + AND (p_company_id IS NULL OR po.company_id = p_company_id) + GROUP BY po.partner_id, po.company_id + ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) + DO UPDATE SET + total_purchases = EXCLUDED.total_purchases, + purchase_order_count = EXCLUDED.purchase_order_count, + avg_purchase_value = EXCLUDED.avg_purchase_value, + calculated_at = NOW(); + + GET DIAGNOSTICS v_suppliers_ranked = ROW_COUNT; + + -- 3. Calcular rankings de clientes (por total de ventas) + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY total_sales DESC) as rank, + total_sales, + SUM(total_sales) OVER () as grand_total, + SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_total + FROM core.partner_rankings + WHERE tenant_id = p_tenant_id + AND period_start = p_period_start + AND period_end = p_period_end + AND total_sales > 0 + ) + UPDATE core.partner_rankings pr + SET + sales_rank = r.rank, + customer_abc = CASE + WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' + WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' + ELSE 'C' + END, + customer_score = CASE + WHEN r.rank = 1 THEN 100 + ELSE GREATEST(0, 100 - (r.rank - 1) * 5) + END + FROM ranked r + WHERE pr.id = r.id; + + -- 4. Calcular rankings de proveedores (por total de compras) + WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER (ORDER BY total_purchases DESC) as rank, + total_purchases, + SUM(total_purchases) OVER () as grand_total, + SUM(total_purchases) OVER (ORDER BY total_purchases DESC) as cumulative_total + FROM core.partner_rankings + WHERE tenant_id = p_tenant_id + AND period_start = p_period_start + AND period_end = p_period_end + AND total_purchases > 0 + ) + UPDATE core.partner_rankings pr + SET + purchase_rank = r.rank, + supplier_abc = CASE + WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' + WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' + ELSE 'C' + END, + supplier_score = CASE + WHEN r.rank = 1 THEN 100 + ELSE GREATEST(0, 100 - (r.rank - 1) * 5) + END + FROM ranked r + WHERE pr.id = r.id; + + -- 5. Calcular score overall + UPDATE core.partner_rankings + SET overall_score = COALESCE( + (COALESCE(customer_score, 0) + COALESCE(supplier_score, 0)) / + NULLIF( + CASE WHEN customer_score IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN supplier_score IS NOT NULL THEN 1 ELSE 0 END, + 0 + ), + 0 + ) + WHERE tenant_id = p_tenant_id + AND period_start = p_period_start + AND period_end = p_period_end; + + -- 6. Actualizar campos desnormalizados en partners + UPDATE core.partners p + SET + customer_rank = pr.sales_rank, + supplier_rank = pr.purchase_rank, + customer_abc = pr.customer_abc, + supplier_abc = pr.supplier_abc, + total_sales_ytd = pr.total_sales, + total_purchases_ytd = pr.total_purchases, + last_ranking_date = CURRENT_DATE + FROM core.partner_rankings pr + WHERE p.id = pr.partner_id + AND p.tenant_id = p_tenant_id + AND pr.tenant_id = p_tenant_id + AND pr.period_start = p_period_start + AND pr.period_end = p_period_end; + + GET DIAGNOSTICS v_partners_processed = ROW_COUNT; + + RETURN QUERY SELECT v_partners_processed, v_customers_ranked, v_suppliers_ranked; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION core.calculate_partner_rankings IS +'Calcula rankings ABC de partners basado en ventas/compras. +Parámetros: + - p_tenant_id: Tenant obligatorio + - p_company_id: Opcional, filtrar por empresa + - p_period_start: Inicio del período (default: hace 1 año) + - p_period_end: Fin del período (default: hoy)'; + +-- ============================================================================ +-- 6. VISTA: Top Partners +-- ============================================================================ + +CREATE OR REPLACE VIEW core.top_partners_view AS +SELECT + p.id, + p.tenant_id, + p.name, + p.email, + p.is_customer, + p.is_supplier, + p.customer_rank, + p.supplier_rank, + p.customer_abc, + p.supplier_abc, + p.total_sales_ytd, + p.total_purchases_ytd, + p.last_ranking_date, + CASE + WHEN p.customer_abc = 'A' THEN 'Cliente VIP' + WHEN p.customer_abc = 'B' THEN 'Cliente Regular' + WHEN p.customer_abc = 'C' THEN 'Cliente Ocasional' + ELSE NULL + END as customer_category, + CASE + WHEN p.supplier_abc = 'A' THEN 'Proveedor Estratégico' + WHEN p.supplier_abc = 'B' THEN 'Proveedor Regular' + WHEN p.supplier_abc = 'C' THEN 'Proveedor Ocasional' + ELSE NULL + END as supplier_category +FROM core.partners p +WHERE p.deleted_at IS NULL + AND (p.customer_rank IS NOT NULL OR p.supplier_rank IS NOT NULL); + +-- ============================================================================ +-- ROLLBACK SCRIPT +-- ============================================================================ +/* +DROP VIEW IF EXISTS core.top_partners_view; +DROP FUNCTION IF EXISTS core.calculate_partner_rankings(UUID, UUID, DATE, DATE); +DROP TABLE IF EXISTS core.partner_rankings; + +ALTER TABLE core.partners + DROP COLUMN IF EXISTS customer_rank, + DROP COLUMN IF EXISTS supplier_rank, + DROP COLUMN IF EXISTS customer_abc, + DROP COLUMN IF EXISTS supplier_abc, + DROP COLUMN IF EXISTS last_ranking_date, + DROP COLUMN IF EXISTS total_sales_ytd, + DROP COLUMN IF EXISTS total_purchases_ytd; +*/ + +-- ============================================================================ +-- VERIFICACIÓN +-- ============================================================================ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'partner_rankings') THEN + RAISE EXCEPTION 'Error: Tabla partner_rankings no fue creada'; + END IF; + + RAISE NOTICE 'Migración completada exitosamente: Partner Rankings'; +END $$; diff --git a/projects/erp-suite/apps/erp-core/database/migrations/20251212_003_financial_reports.sql b/projects/erp-suite/apps/erp-core/database/migrations/20251212_003_financial_reports.sql new file mode 100644 index 0000000..7203e8f --- /dev/null +++ b/projects/erp-suite/apps/erp-core/database/migrations/20251212_003_financial_reports.sql @@ -0,0 +1,464 @@ +-- ============================================================================ +-- MIGRACIÓN: Sistema de Reportes Financieros +-- Fecha: 2025-12-12 +-- Descripción: Crea tablas para definición, ejecución y programación de reportes +-- Impacto: Módulo financiero y verticales que requieren reportes contables +-- Rollback: DROP TABLE y DROP FUNCTION incluidos al final +-- ============================================================================ + +-- ============================================================================ +-- 1. TABLA DE DEFINICIONES DE REPORTES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS reports.report_definitions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificación + code VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + + -- Clasificación + report_type VARCHAR(50) NOT NULL DEFAULT 'financial', + -- financial, accounting, tax, management, custom + category VARCHAR(100), + -- balance_sheet, income_statement, cash_flow, trial_balance, ledger, etc. + + -- Configuración de consulta + base_query TEXT, -- SQL base o referencia a función + query_function VARCHAR(255), -- Nombre de función PostgreSQL si usa función + + -- Parámetros requeridos (JSON Schema) + parameters_schema JSONB DEFAULT '{}', + -- Ejemplo: {"date_from": {"type": "date", "required": true}, "company_id": {"type": "uuid"}} + + -- Configuración de columnas + columns_config JSONB DEFAULT '[]', + -- Ejemplo: [{"name": "account", "label": "Cuenta", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}] + + -- Agrupaciones disponibles + grouping_options JSONB DEFAULT '[]', + -- Ejemplo: ["account_type", "company", "period"] + + -- Configuración de totales + totals_config JSONB DEFAULT '{}', + -- Ejemplo: {"show_totals": true, "total_columns": ["debit", "credit", "balance"]} + + -- Plantillas de exportación + export_formats JSONB DEFAULT '["pdf", "xlsx", "csv"]', + pdf_template VARCHAR(255), -- Referencia a plantilla PDF + xlsx_template VARCHAR(255), + + -- Estado y visibilidad + is_system BOOLEAN DEFAULT false, -- Reportes del sistema vs personalizados + is_active BOOLEAN DEFAULT true, + + -- Permisos requeridos + required_permissions JSONB DEFAULT '[]', + -- Ejemplo: ["financial.reports.view", "financial.reports.balance_sheet"] + + -- Metadata + version INTEGER DEFAULT 1, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES auth.users(id), + + -- Constraints + UNIQUE(tenant_id, code) +); + +COMMENT ON TABLE reports.report_definitions IS +'Definiciones de reportes disponibles en el sistema. Incluye reportes predefinidos y personalizados.'; + +-- ============================================================================ +-- 2. TABLA DE EJECUCIONES DE REPORTES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS reports.report_executions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, + + -- Parámetros de ejecución + parameters JSONB NOT NULL DEFAULT '{}', + -- Los valores específicos usados para esta ejecución + + -- Estado de ejecución + status VARCHAR(20) NOT NULL DEFAULT 'pending', + -- pending, running, completed, failed, cancelled + + -- Tiempos + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + execution_time_ms INTEGER, + + -- Resultados + row_count INTEGER, + result_data JSONB, -- Datos del reporte (puede ser grande) + result_summary JSONB, -- Resumen/totales + + -- Archivos generados + output_files JSONB DEFAULT '[]', + -- Ejemplo: [{"format": "pdf", "path": "/reports/...", "size": 12345}] + + -- Errores + error_message TEXT, + error_details JSONB, + + -- Metadata + requested_by UUID NOT NULL REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +COMMENT ON TABLE reports.report_executions IS +'Historial de ejecuciones de reportes con sus resultados y archivos generados.'; + +-- ============================================================================ +-- 3. TABLA DE PROGRAMACIÓN DE REPORTES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS reports.report_schedules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, + company_id UUID REFERENCES auth.companies(id) ON DELETE CASCADE, + + -- Nombre del schedule + name VARCHAR(255) NOT NULL, + + -- Parámetros predeterminados + default_parameters JSONB DEFAULT '{}', + + -- Programación (cron expression) + cron_expression VARCHAR(100) NOT NULL, + -- Ejemplo: "0 8 1 * *" (primer día del mes a las 8am) + + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + + -- Estado + is_active BOOLEAN DEFAULT true, + + -- Última ejecución + last_execution_id UUID REFERENCES reports.report_executions(id), + last_run_at TIMESTAMPTZ, + next_run_at TIMESTAMPTZ, + + -- Destino de entrega + delivery_method VARCHAR(50) DEFAULT 'none', + -- none, email, storage, webhook + delivery_config JSONB DEFAULT '{}', + -- Para email: {"recipients": ["a@b.com"], "subject": "...", "format": "pdf"} + -- Para storage: {"path": "/reports/scheduled/", "retention_days": 30} + + -- Metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES auth.users(id) +); + +COMMENT ON TABLE reports.report_schedules IS +'Programación automática de reportes con opciones de entrega.'; + +-- ============================================================================ +-- 4. TABLA DE PLANTILLAS DE REPORTES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS reports.report_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificación + code VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + + -- Tipo de plantilla + template_type VARCHAR(20) NOT NULL, + -- pdf, xlsx, html + + -- Contenido de la plantilla + template_content BYTEA, -- Para plantillas binarias (XLSX) + template_html TEXT, -- Para plantillas HTML/PDF + + -- Estilos CSS (para PDF/HTML) + styles TEXT, + + -- Variables disponibles + available_variables JSONB DEFAULT '[]', + + -- Estado + is_active BOOLEAN DEFAULT true, + + -- Metadata + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + UNIQUE(tenant_id, code) +); + +COMMENT ON TABLE reports.report_templates IS +'Plantillas personalizables para la generación de reportes en diferentes formatos.'; + +-- ============================================================================ +-- 5. ÍNDICES +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_type + ON reports.report_definitions(tenant_id, report_type); + +CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_category + ON reports.report_definitions(tenant_id, category); + +CREATE INDEX IF NOT EXISTS idx_report_executions_tenant_status + ON reports.report_executions(tenant_id, status); + +CREATE INDEX IF NOT EXISTS idx_report_executions_definition + ON reports.report_executions(definition_id); + +CREATE INDEX IF NOT EXISTS idx_report_executions_created + ON reports.report_executions(tenant_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_report_schedules_next_run + ON reports.report_schedules(next_run_at) + WHERE is_active = true; + +-- ============================================================================ +-- 6. RLS (Row Level Security) +-- ============================================================================ + +ALTER TABLE reports.report_definitions ENABLE ROW LEVEL SECURITY; +ALTER TABLE reports.report_executions ENABLE ROW LEVEL SECURITY; +ALTER TABLE reports.report_schedules ENABLE ROW LEVEL SECURITY; +ALTER TABLE reports.report_templates ENABLE ROW LEVEL SECURITY; + +-- Políticas para report_definitions +DROP POLICY IF EXISTS report_definitions_tenant_isolation ON reports.report_definitions; +CREATE POLICY report_definitions_tenant_isolation ON reports.report_definitions + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Políticas para report_executions +DROP POLICY IF EXISTS report_executions_tenant_isolation ON reports.report_executions; +CREATE POLICY report_executions_tenant_isolation ON reports.report_executions + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Políticas para report_schedules +DROP POLICY IF EXISTS report_schedules_tenant_isolation ON reports.report_schedules; +CREATE POLICY report_schedules_tenant_isolation ON reports.report_schedules + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Políticas para report_templates +DROP POLICY IF EXISTS report_templates_tenant_isolation ON reports.report_templates; +CREATE POLICY report_templates_tenant_isolation ON reports.report_templates + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ============================================================================ +-- 7. FUNCIONES DE REPORTES PREDEFINIDOS +-- ============================================================================ + +-- Balance de Comprobación +CREATE OR REPLACE FUNCTION reports.generate_trial_balance( + p_tenant_id UUID, + p_company_id UUID, + p_date_from DATE, + p_date_to DATE, + p_include_zero_balance BOOLEAN DEFAULT false +) +RETURNS TABLE ( + account_id UUID, + account_code VARCHAR(20), + account_name VARCHAR(255), + account_type VARCHAR(50), + initial_debit DECIMAL(16,2), + initial_credit DECIMAL(16,2), + period_debit DECIMAL(16,2), + period_credit DECIMAL(16,2), + final_debit DECIMAL(16,2), + final_credit DECIMAL(16,2) +) AS $$ +BEGIN + RETURN QUERY + WITH account_balances AS ( + -- Saldos iniciales (antes del período) + SELECT + a.id as account_id, + a.code as account_code, + a.name as account_name, + a.account_type, + COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.debit ELSE 0 END), 0) as initial_debit, + COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.credit ELSE 0 END), 0) as initial_credit, + COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.debit ELSE 0 END), 0) as period_debit, + COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.credit ELSE 0 END), 0) as period_credit + FROM financial.accounts a + LEFT JOIN financial.journal_entry_lines jel ON a.id = jel.account_id + LEFT JOIN financial.journal_entries je ON jel.journal_entry_id = je.id AND je.status = 'posted' + WHERE a.tenant_id = p_tenant_id + AND (p_company_id IS NULL OR a.company_id = p_company_id) + AND a.is_active = true + GROUP BY a.id, a.code, a.name, a.account_type + ) + SELECT + ab.account_id, + ab.account_code, + ab.account_name, + ab.account_type, + ab.initial_debit, + ab.initial_credit, + ab.period_debit, + ab.period_credit, + ab.initial_debit + ab.period_debit as final_debit, + ab.initial_credit + ab.period_credit as final_credit + FROM account_balances ab + WHERE p_include_zero_balance = true + OR (ab.initial_debit + ab.period_debit) != 0 + OR (ab.initial_credit + ab.period_credit) != 0 + ORDER BY ab.account_code; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION reports.generate_trial_balance IS +'Genera el balance de comprobación para un período específico.'; + +-- Libro Mayor +CREATE OR REPLACE FUNCTION reports.generate_general_ledger( + p_tenant_id UUID, + p_company_id UUID, + p_account_id UUID, + p_date_from DATE, + p_date_to DATE +) +RETURNS TABLE ( + entry_date DATE, + journal_entry_id UUID, + entry_number VARCHAR(50), + description TEXT, + partner_name VARCHAR(255), + debit DECIMAL(16,2), + credit DECIMAL(16,2), + running_balance DECIMAL(16,2) +) AS $$ +BEGIN + RETURN QUERY + WITH movements AS ( + SELECT + je.entry_date, + je.id as journal_entry_id, + je.entry_number, + je.description, + p.name as partner_name, + jel.debit, + jel.credit, + ROW_NUMBER() OVER (ORDER BY je.entry_date, je.id) as rn + FROM financial.journal_entry_lines jel + JOIN financial.journal_entries je ON jel.journal_entry_id = je.id + LEFT JOIN core.partners p ON je.partner_id = p.id + WHERE jel.account_id = p_account_id + AND jel.tenant_id = p_tenant_id + AND je.status = 'posted' + AND je.entry_date BETWEEN p_date_from AND p_date_to + AND (p_company_id IS NULL OR je.company_id = p_company_id) + ORDER BY je.entry_date, je.id + ) + SELECT + m.entry_date, + m.journal_entry_id, + m.entry_number, + m.description, + m.partner_name, + m.debit, + m.credit, + SUM(m.debit - m.credit) OVER (ORDER BY m.rn) as running_balance + FROM movements m; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION reports.generate_general_ledger IS +'Genera el libro mayor para una cuenta específica.'; + +-- ============================================================================ +-- 8. DATOS SEMILLA: REPORTES PREDEFINIDOS DEL SISTEMA +-- ============================================================================ + +-- Nota: Los reportes del sistema se insertan con is_system = true +-- y se insertan solo si no existen (usando ON CONFLICT) + +DO $$ +DECLARE + v_system_tenant_id UUID; +BEGIN + -- Obtener el tenant del sistema (si existe) + SELECT id INTO v_system_tenant_id + FROM auth.tenants + WHERE code = 'system' OR is_system = true + LIMIT 1; + + -- Solo insertar si hay un tenant sistema + IF v_system_tenant_id IS NOT NULL THEN + -- Balance de Comprobación + INSERT INTO reports.report_definitions ( + tenant_id, code, name, description, report_type, category, + query_function, parameters_schema, columns_config, is_system + ) VALUES ( + v_system_tenant_id, + 'TRIAL_BALANCE', + 'Balance de Comprobación', + 'Reporte de balance de comprobación con saldos iniciales, movimientos y saldos finales', + 'financial', + 'trial_balance', + 'reports.generate_trial_balance', + '{"company_id": {"type": "uuid", "required": false}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}, "include_zero": {"type": "boolean", "default": false}}', + '[{"name": "account_code", "label": "Código", "type": "string"}, {"name": "account_name", "label": "Cuenta", "type": "string"}, {"name": "initial_debit", "label": "Debe Inicial", "type": "currency"}, {"name": "initial_credit", "label": "Haber Inicial", "type": "currency"}, {"name": "period_debit", "label": "Debe Período", "type": "currency"}, {"name": "period_credit", "label": "Haber Período", "type": "currency"}, {"name": "final_debit", "label": "Debe Final", "type": "currency"}, {"name": "final_credit", "label": "Haber Final", "type": "currency"}]', + true + ) ON CONFLICT (tenant_id, code) DO NOTHING; + + -- Libro Mayor + INSERT INTO reports.report_definitions ( + tenant_id, code, name, description, report_type, category, + query_function, parameters_schema, columns_config, is_system + ) VALUES ( + v_system_tenant_id, + 'GENERAL_LEDGER', + 'Libro Mayor', + 'Detalle de movimientos por cuenta con saldo acumulado', + 'financial', + 'ledger', + 'reports.generate_general_ledger', + '{"company_id": {"type": "uuid", "required": false}, "account_id": {"type": "uuid", "required": true}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}}', + '[{"name": "entry_date", "label": "Fecha", "type": "date"}, {"name": "entry_number", "label": "Número", "type": "string"}, {"name": "description", "label": "Descripción", "type": "string"}, {"name": "partner_name", "label": "Tercero", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}, {"name": "credit", "label": "Haber", "type": "currency"}, {"name": "running_balance", "label": "Saldo", "type": "currency"}]', + true + ) ON CONFLICT (tenant_id, code) DO NOTHING; + + RAISE NOTICE 'Reportes del sistema insertados correctamente'; + END IF; +END $$; + +-- ============================================================================ +-- ROLLBACK SCRIPT +-- ============================================================================ +/* +DROP FUNCTION IF EXISTS reports.generate_general_ledger(UUID, UUID, UUID, DATE, DATE); +DROP FUNCTION IF EXISTS reports.generate_trial_balance(UUID, UUID, DATE, DATE, BOOLEAN); +DROP TABLE IF EXISTS reports.report_templates; +DROP TABLE IF EXISTS reports.report_schedules; +DROP TABLE IF EXISTS reports.report_executions; +DROP TABLE IF EXISTS reports.report_definitions; +*/ + +-- ============================================================================ +-- VERIFICACIÓN +-- ============================================================================ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_definitions') THEN + RAISE EXCEPTION 'Error: Tabla report_definitions no fue creada'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_executions') THEN + RAISE EXCEPTION 'Error: Tabla report_executions no fue creada'; + END IF; + + RAISE NOTICE 'Migración completada exitosamente: Reportes Financieros'; +END $$; diff --git a/projects/erp-suite/apps/verticales/construccion/.env.example b/projects/erp-suite/apps/verticales/construccion/.env.example new file mode 100644 index 0000000..07c0215 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/.env.example @@ -0,0 +1,119 @@ +# ============================================================================= +# ERP Construccion - Environment Variables +# ============================================================================= +# Copia este archivo a .env y configura los valores +# cp .env.example .env + +# ----------------------------------------------------------------------------- +# APPLICATION +# ----------------------------------------------------------------------------- +NODE_ENV=development +APP_PORT=3000 +API_VERSION=v1 + +# ----------------------------------------------------------------------------- +# DATABASE - PostgreSQL +# ----------------------------------------------------------------------------- +DB_HOST=localhost +DB_PORT=5432 +DB_USER=construccion +DB_PASSWORD=construccion_dev_2024 +DB_NAME=erp_construccion +DB_SCHEMA=public + +# Database Pool +DB_POOL_MIN=2 +DB_POOL_MAX=10 + +# ----------------------------------------------------------------------------- +# REDIS +# ----------------------------------------------------------------------------- +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=redis_dev_2024 + +# ----------------------------------------------------------------------------- +# JWT & AUTHENTICATION +# ----------------------------------------------------------------------------- +JWT_SECRET=your-super-secret-jwt-key-change-in-production-minimum-32-chars +JWT_EXPIRES_IN=1d +JWT_REFRESH_EXPIRES_IN=7d + +# ----------------------------------------------------------------------------- +# CORS +# ----------------------------------------------------------------------------- +CORS_ORIGIN=http://localhost:5173,http://localhost:3001 +CORS_CREDENTIALS=true + +# ----------------------------------------------------------------------------- +# LOGGING +# ----------------------------------------------------------------------------- +LOG_LEVEL=debug +LOG_FORMAT=dev + +# ----------------------------------------------------------------------------- +# FILE STORAGE (S3 Compatible) +# ----------------------------------------------------------------------------- +STORAGE_TYPE=local +# STORAGE_TYPE=s3 +# S3_BUCKET=construccion-files +# S3_REGION=us-east-1 +# S3_ACCESS_KEY_ID=your-access-key +# S3_SECRET_ACCESS_KEY=your-secret-key +# S3_ENDPOINT=https://s3.amazonaws.com + +# Local storage path (when STORAGE_TYPE=local) +UPLOAD_PATH=./uploads + +# ----------------------------------------------------------------------------- +# EMAIL (SMTP) +# ----------------------------------------------------------------------------- +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@construccion.local + +# ----------------------------------------------------------------------------- +# WHATSAPP BUSINESS API (Optional) +# ----------------------------------------------------------------------------- +# WHATSAPP_API_URL=https://graph.facebook.com/v17.0 +# WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id +# WHATSAPP_ACCESS_TOKEN=your-access-token +# WHATSAPP_VERIFY_TOKEN=your-verify-token + +# ----------------------------------------------------------------------------- +# INTEGRATIONS (Optional) +# ----------------------------------------------------------------------------- +# IMSS +# IMSS_API_URL=https://api.imss.gob.mx +# IMSS_CERTIFICATE_PATH=/certs/imss.p12 +# IMSS_CERTIFICATE_PASSWORD= + +# INFONAVIT +# INFONAVIT_API_URL=https://api.infonavit.org.mx +# INFONAVIT_CLIENT_ID= +# INFONAVIT_CLIENT_SECRET= + +# SAT (CFDI) +# PAC_URL=https://api.pac.com.mx +# PAC_USER= +# PAC_PASSWORD= + +# ----------------------------------------------------------------------------- +# FEATURE FLAGS +# ----------------------------------------------------------------------------- +FEATURE_HSE_AI=false +FEATURE_WHATSAPP_BOT=false +FEATURE_BIOMETRIC=false + +# ----------------------------------------------------------------------------- +# DOCKER COMPOSE OVERRIDES +# ----------------------------------------------------------------------------- +# Used by docker-compose.yml +BACKEND_PORT=3000 +FRONTEND_PORT=5173 +ADMINER_PORT=8080 +MAILHOG_SMTP_PORT=1025 +MAILHOG_WEB_PORT=8025 +BUILD_TARGET=development diff --git a/projects/erp-suite/apps/verticales/construccion/.github/workflows/ci.yml b/projects/erp-suite/apps/verticales/construccion/.github/workflows/ci.yml new file mode 100644 index 0000000..06ddc32 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/.github/workflows/ci.yml @@ -0,0 +1,263 @@ +# ============================================================================= +# CI Pipeline - ERP Construccion +# Runs on every push and pull request +# ============================================================================= + +name: CI Pipeline + +on: + push: + branches: [main, develop, 'feature/**'] + pull_request: + branches: [main, develop] + +env: + NODE_VERSION: '20' + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_db + +jobs: + # =========================================================================== + # Lint & Type Check + # =========================================================================== + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: | + backend/package-lock.json + frontend/web/package-lock.json + + - name: Install Backend Dependencies + working-directory: backend + run: npm ci + + - name: Install Frontend Dependencies + working-directory: frontend/web + run: npm ci + + - name: Lint Backend + working-directory: backend + run: npm run lint + + - name: Lint Frontend + working-directory: frontend/web + run: npm run lint || true + + - name: Type Check Backend + working-directory: backend + run: npm run build -- --noEmit || npm run build + + # =========================================================================== + # Validate Constants (SSOT) + # =========================================================================== + validate-constants: + name: Validate SSOT Constants + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install Dependencies + working-directory: backend + run: npm ci + + - name: Run Constants Validation + working-directory: backend + run: npm run validate:constants || echo "Validation script not yet implemented" + + # =========================================================================== + # Unit Tests - Backend + # =========================================================================== + test-backend: + name: Backend Tests + runs-on: ubuntu-latest + needs: [lint] + services: + postgres: + image: postgis/postgis:15-3.3-alpine + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: backend/package-lock.json + + - name: Install Dependencies + working-directory: backend + run: npm ci + + - name: Run Unit Tests + working-directory: backend + run: npm run test -- --coverage --passWithNoTests + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: ${{ env.POSTGRES_USER }} + DB_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + DB_NAME: ${{ env.POSTGRES_DB }} + REDIS_HOST: localhost + REDIS_PORT: 6379 + + - name: Upload Coverage Report + uses: codecov/codecov-action@v3 + if: always() + with: + files: backend/coverage/lcov.info + flags: backend + fail_ci_if_error: false + + # =========================================================================== + # Unit Tests - Frontend + # =========================================================================== + test-frontend: + name: Frontend Tests + runs-on: ubuntu-latest + needs: [lint] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: frontend/web/package-lock.json + + - name: Install Dependencies + working-directory: frontend/web + run: npm ci + + - name: Run Unit Tests + working-directory: frontend/web + run: npm run test -- --coverage --passWithNoTests || echo "No tests yet" + + - name: Upload Coverage Report + uses: codecov/codecov-action@v3 + if: always() + with: + files: frontend/web/coverage/lcov.info + flags: frontend + fail_ci_if_error: false + + # =========================================================================== + # Build Check + # =========================================================================== + build: + name: Build + runs-on: ubuntu-latest + needs: [test-backend, test-frontend] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Build Backend + working-directory: backend + run: | + npm ci + npm run build + + - name: Build Frontend + working-directory: frontend/web + run: | + npm ci + npm run build + + - name: Upload Backend Artifacts + uses: actions/upload-artifact@v3 + with: + name: backend-dist + path: backend/dist + retention-days: 7 + + - name: Upload Frontend Artifacts + uses: actions/upload-artifact@v3 + with: + name: frontend-dist + path: frontend/web/dist + retention-days: 7 + + # =========================================================================== + # Docker Build (only on main/develop) + # =========================================================================== + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: [build] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Backend Docker Image + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + target: production + push: false + tags: construccion-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build Frontend Docker Image + uses: docker/build-push-action@v5 + with: + context: ./frontend/web + file: ./frontend/web/Dockerfile + target: production + push: false + tags: construccion-frontend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/projects/erp-suite/apps/verticales/construccion/PROJECT-STATUS.md b/projects/erp-suite/apps/verticales/construccion/PROJECT-STATUS.md index 93eeb3c..f10189e 100644 --- a/projects/erp-suite/apps/verticales/construccion/PROJECT-STATUS.md +++ b/projects/erp-suite/apps/verticales/construccion/PROJECT-STATUS.md @@ -1,9 +1,87 @@ -# ESTADO DEL PROYECTO - ERP Construcción +# ESTADO DEL PROYECTO - ERP Construccion -**Proyecto:** ERP Construcción (Proyecto Independiente) +**Proyecto:** ERP Construccion (Proyecto Independiente) **Estado:** 🚧 En desarrollo -**Progreso:** 35% -**Última actualización:** 2025-12-08 +**Progreso:** 55% +**Ultima actualizacion:** 2025-12-12 + +--- + +## 🆕 CAMBIOS RECIENTES (2025-12-12) + +### Fase 2: Backend Core Modules - EN PROGRESO + +- ✅ **MAI-003 Presupuestos - Entidades y Services** + - `Concepto` entity - Catálogo jerárquico de conceptos + - `Presupuesto` entity - Presupuestos versionados + - `PresupuestoPartida` entity - Líneas de presupuesto + - `ConceptoService` - Árbol de conceptos, búsqueda + - `PresupuestoService` - CRUD, versionamiento, aprobación + +- ✅ **MAI-005 Control de Obra - Entidades y Services** + - `AvanceObra` entity - Avances físicos + - `FotoAvance` entity - Evidencias fotográficas con GPS + - `BitacoraObra` entity - Bitácora diaria + - `ProgramaObra` entity - Programa maestro + - `ProgramaActividad` entity - WBS/Actividades + - `AvanceObraService` - Workflow captura→revisión→aprobación + - `BitacoraObraService` - Entradas secuenciales, estadísticas + +- ✅ **MAI-008 Estimaciones - Entidades y Services** + - `Estimacion` entity - Estimaciones periódicas + - `EstimacionConcepto` entity - Líneas con acumulados + - `Generador` entity - Números generadores + - `Anticipo` entity - Anticipos con amortización + - `Amortizacion` entity - Descuentos por estimación + - `Retencion` entity - Fondo de garantía, impuestos + - `FondoGarantia` entity - Acumulado por contrato + - `EstimacionWorkflow` entity - Historial de estados + - `EstimacionService` - Workflow completo, cálculo de totales + +- ✅ **Módulo Auth - JWT + Refresh Tokens** + - `AuthService` - Login, register, refresh, logout + - `AuthMiddleware` - Autenticación, autorización por roles + - DTOs tipados para todas las operaciones + - Configuración de RLS con tenant_id + +- ✅ **Base Service Pattern** + - `BaseService` - CRUD multi-tenant genérico + - Paginación con metadata + - Soft delete con audit columns + - Contexto de servicio (tenantId, userId) + +### Fase 1: Fundamentos Arquitectonicos - COMPLETADA + +- ✅ **Sistema SSOT implementado** + - `database.constants.ts` - Schemas, tablas, columnas + - `api.constants.ts` - Rutas API centralizadas + - `enums.constants.ts` - Todos los enums del sistema + - Index actualizado con exports centralizados + +- ✅ **Path Aliases configurados** (ya existian) + - `@shared/*`, `@modules/*`, `@config/*`, `@types/*`, `@utils/*` + +- ✅ **Docker + docker-compose** + - PostgreSQL 15 + PostGIS + - Redis 7 + - Backend Node.js + - Frontend React + Vite + - Adminer (dev) + - Mailhog (dev) + +- ✅ **CI/CD GitHub Actions** + - Lint & Type Check + - Validate SSOT Constants + - Unit Tests (Backend + Frontend) + - Build Check + - Docker Build + +- ✅ **Scripts de validacion** + - `validate-constants-usage.ts` - Detecta hardcoding + - `sync-enums.ts` - Sincroniza Backend → Frontend + +- ✅ **Documentacion de DB** + - `database/_MAP.md` - Mapa completo de schemas --- @@ -11,8 +89,8 @@ | Área | Implementado | Documentado | Estado | |------|-------------|-------------|--------| -| **DDL/Schemas** | 3 schemas, 33 tablas | 7 schemas, 65 tablas | 50% | -| **Backend** | 4 módulos, 11 entidades | 18 módulos | 22% | +| **DDL/Schemas** | 7 schemas, 110 tablas | 7 schemas, 110 tablas | 100% | +| **Backend** | 7 módulos, 30 entidades | 18 módulos | 40% | | **Frontend** | Estructura base | 18 módulos | 5% | | **Documentación** | - | 449 archivos MD | 100% | @@ -23,16 +101,20 @@ ### Schemas Implementados (DDL) | Schema | Tablas | ENUMs | Archivo DDL | |--------|--------|-------|-------------| -| `construction` | 2 | - | `01-construction-schema-ddl.sql` | -| `hr` | 3 | - | `02-hr-schema-ddl.sql` | -| `hse` | 28 | 67 | `03-hse-schema-ddl.sql` | -| **Total** | **33** | **67** | | +| `construction` | 24 | 7 | `01-construction-schema-ddl.sql` | +| `hr` | 8 | - | `02-hr-schema-ddl.sql` | +| `hse` | 58 | 67 | `03-hse-schema-ddl.sql` | +| `estimates` | 8 | 4 | `04-estimates-schema-ddl.sql` | +| `infonavit` | 8 | - | `05-infonavit-schema-ddl.sql` | +| `inventory` | 4 | - | `06-inventory-ext-schema-ddl.sql` | +| `purchase` | 5 | - | `07-purchase-ext-schema-ddl.sql` | +| **Total** | **110** | **78** | | -### Schemas Pendientes de Implementar -- `estimates` - Presupuestos y estimaciones (8 tablas documentadas) -- `infonavit` - Integración INFONAVIT (8 tablas documentadas) -- `inventory-ext` - Extensión inventario (4 tablas documentadas) -- `purchase-ext` - Extensión compras (5 tablas documentadas) +### DDL Completo +Todos los schemas han sido implementados con: +- RLS (Row Level Security) para multi-tenancy +- Indices optimizados +- Funciones auxiliares (ej: `calculate_estimate_totals`) --- @@ -41,27 +123,58 @@ ### Módulos con Código ``` backend/src/modules/ -├── construction/ ✅ Entidades + Services + Controllers -│ ├── proyecto.entity.ts -│ └── fraccionamiento.entity.ts -├── hr/ ✅ Entidades básicas -│ ├── employee.entity.ts -│ ├── puesto.entity.ts -│ └── employee-fraccionamiento.entity.ts -├── hse/ ✅ Entidades básicas -│ ├── incidente.entity.ts -│ ├── incidente-involucrado.entity.ts -│ ├── incidente-accion.entity.ts -│ └── capacitacion.entity.ts -└── core/ ✅ Base multi-tenant - ├── user.entity.ts - └── tenant.entity.ts +├── auth/ ✅ Autenticación JWT completa +│ ├── services/auth.service.ts +│ ├── middleware/auth.middleware.ts +│ └── dto/auth.dto.ts +├── budgets/ ✅ Presupuestos (MAI-003) +│ ├── entities/concepto.entity.ts +│ ├── entities/presupuesto.entity.ts +│ ├── entities/presupuesto-partida.entity.ts +│ ├── services/concepto.service.ts +│ └── services/presupuesto.service.ts +├── progress/ ✅ Control de Obra (MAI-005) +│ ├── entities/avance-obra.entity.ts +│ ├── entities/foto-avance.entity.ts +│ ├── entities/bitacora-obra.entity.ts +│ ├── entities/programa-obra.entity.ts +│ ├── entities/programa-actividad.entity.ts +│ ├── services/avance-obra.service.ts +│ └── services/bitacora-obra.service.ts +├── estimates/ ✅ Estimaciones (MAI-008) +│ ├── entities/estimacion.entity.ts +│ ├── entities/estimacion-concepto.entity.ts +│ ├── entities/generador.entity.ts +│ ├── entities/anticipo.entity.ts +│ ├── entities/amortizacion.entity.ts +│ ├── entities/retencion.entity.ts +│ ├── entities/fondo-garantia.entity.ts +│ ├── entities/estimacion-workflow.entity.ts +│ └── services/estimacion.service.ts +├── construction/ ✅ Proyectos (MAI-002) +│ ├── entities/proyecto.entity.ts +│ └── entities/fraccionamiento.entity.ts +├── hr/ ✅ RRHH (MAI-007) +│ ├── entities/employee.entity.ts +│ ├── entities/puesto.entity.ts +│ └── entities/employee-fraccionamiento.entity.ts +├── hse/ ✅ HSE (MAA-017) +│ ├── entities/incidente.entity.ts +│ ├── entities/incidente-involucrado.entity.ts +│ ├── entities/incidente-accion.entity.ts +│ └── entities/capacitacion.entity.ts +├── core/ ✅ Base multi-tenant +│ ├── entities/user.entity.ts +│ └── entities/tenant.entity.ts +└── shared/ ✅ Servicios compartidos + └── services/base.service.ts ``` ### Pendientes -- 14 módulos MAI sin código backend +- Controllers REST para módulos nuevos +- 8 módulos MAI sin código backend - 3 módulos MAE sin código backend -- Services y Controllers para hr, hse +- Frontend integración con API --- @@ -72,16 +185,16 @@ backend/src/modules/ |--------|--------|:---:|:-------:|:----:| | MAI-001 | Fundamentos | - | ✅ | ✅ | | MAI-002 | Proyectos y Estructura | ✅ | ✅ | ✅ | -| MAI-003 | Presupuestos y Costos | ⏳ | ❌ | ✅ | -| MAI-004 | Compras e Inventarios | ⏳ | ❌ | ✅ | -| MAI-005 | Control de Obra | ⏳ | ❌ | ✅ | +| MAI-003 | Presupuestos y Costos | ✅ | ✅ | ✅ | +| MAI-004 | Compras e Inventarios | ✅ | ⏳ | ✅ | +| MAI-005 | Control de Obra | ✅ | ✅ | ✅ | | MAI-006 | Reportes y Analytics | - | ❌ | ✅ | | MAI-007 | RRHH y Asistencias | ✅ | ✅ | ✅ | -| MAI-008 | Estimaciones | ⏳ | ❌ | ✅ | -| MAI-009 | Calidad y Postventa | ⏳ | ❌ | ✅ | +| MAI-008 | Estimaciones | ✅ | ✅ | ✅ | +| MAI-009 | Calidad y Postventa | ✅ | ⏳ | ✅ | | MAI-010 | CRM Derechohabientes | ⏳ | ❌ | ✅ | -| MAI-011 | INFONAVIT | ⏳ | ❌ | ✅ | -| MAI-012 | Contratos | ⏳ | ❌ | ✅ | +| MAI-011 | INFONAVIT | ✅ | ⏳ | ✅ | +| MAI-012 | Contratos | ✅ | ⏳ | ✅ | | MAI-013 | Administración | - | ❌ | ✅ | | MAI-018 | Preconstrucción | ⏳ | ❌ | ✅ | @@ -97,21 +210,31 @@ backend/src/modules/ |--------|--------|:---:|:-------:|:----:| | MAA-017 | Seguridad HSE | ✅ | ✅ | ✅ | -**Leyenda:** ✅ Implementado | ⏳ Pendiente | ❌ No iniciado | - No aplica +**Leyenda:** ✅ Implementado | ⏳ En progreso | ❌ No iniciado | - No aplica --- ## 🎯 PRÓXIMOS PASOS ### Inmediato -1. Implementar DDL de `estimates` schema -2. Implementar DDL de `infonavit` schema -3. Completar services/controllers de `hr` y `hse` +1. ✅ ~~Implementar DDL de `estimates` schema~~ - COMPLETADO +2. ✅ ~~Implementar DDL de `infonavit` schema~~ - COMPLETADO +3. ✅ ~~Backend MAI-003 Presupuestos~~ - COMPLETADO +4. ✅ ~~Backend MAI-005 Control de Obra~~ - COMPLETADO +5. ✅ ~~Backend MAI-008 Estimaciones~~ - COMPLETADO +6. ✅ ~~Módulo Auth JWT completo~~ - COMPLETADO ### Corto Plazo -4. Implementar backend de MAI-003 (Presupuestos) -5. Implementar backend de MAI-005 (Control de Obra) -6. Testing de módulos existentes +1. Crear Controllers REST para módulos nuevos +2. Implementar backend de MAI-009 (Calidad y Postventa) +3. Implementar backend de MAI-011 (INFONAVIT) +4. Implementar backend de MAI-012 (Contratos) +5. Testing de módulos existentes + +### Mediano Plazo +6. Frontend: Integración con API +7. Frontend: Módulos de Presupuestos y Estimaciones +8. Implementar Curva S y reportes de avance --- @@ -119,8 +242,11 @@ backend/src/modules/ - **DDL:** `database/schemas/*.sql` - **Backend:** `backend/src/modules/` +- **Services:** `backend/src/shared/services/base.service.ts` +- **Auth:** `backend/src/modules/auth/` - **Docs:** `docs/02-definicion-modulos/` - **Inventario:** `orchestration/inventarios/MASTER_INVENTORY.yml` +- **Constants SSOT:** `backend/src/shared/constants/` --- @@ -134,7 +260,10 @@ backend/src/modules/ | User Stories | 149 | | Story Points | 692 | | ADRs | 12 | +| Entidades TypeORM | 30 | +| Services Backend | 8 | +| Tablas DDL | 110 | --- -**Última actualización:** 2025-12-08 +**Última actualización:** 2025-12-12 diff --git a/projects/erp-suite/apps/verticales/construccion/README.md b/projects/erp-suite/apps/verticales/construccion/README.md index 1b5ed43..d0320c2 100644 --- a/projects/erp-suite/apps/verticales/construccion/README.md +++ b/projects/erp-suite/apps/verticales/construccion/README.md @@ -1,19 +1,97 @@ -# ERP Construccion - Vertical INFONAVIT +# ERP Construccion - Sistema de Administracion de Obra e INFONAVIT -## Descripcion +Sistema ERP especializado para empresas de construccion de vivienda con integracion INFONAVIT. Arquitectura multi-tenant SaaS con Row Level Security (RLS). -Vertical especializada del ERP Suite para empresas de construccion con integracion INFONAVIT. **Extiende erp-core** con modulos especificos para gestion de proyectos de construccion, presupuestos, control de obra, y cumplimiento normativo. +## Estado del Proyecto | Campo | Valor | |-------|-------| -| **Estado** | En desarrollo (35%) | -| **Version** | 0.1.0 | -| **Base** | Extiende erp-core (61% reutilizacion) | -| **Modulos** | 18 (14 Fase 1 + 3 Fase 2 + 1 Fase 3) | -| **RF** | 79 | -| **ET** | 78 | -| **US** | 139 | -| **ADRs** | 12 | +| **Estado** | 🚧 En desarrollo (55%) | +| **Version** | 0.2.0 | +| **Modulos** | 18 (14 MAI + 3 MAE + 1 MAA) | +| **DDL Schemas** | 7 (110 tablas) | +| **Entidades Backend** | 30 | +| **Services Backend** | 8 | + +--- + +## Quick Start + +### Prerequisitos + +- Node.js >= 18.0.0 +- Docker & Docker Compose +- PostgreSQL 15+ con PostGIS (incluido en docker-compose) + +### Instalacion + +```bash +# Clonar repositorio +cd apps/verticales/construccion + +# Copiar variables de entorno +cp .env.example .env + +# Levantar servicios con Docker +docker-compose up -d + +# O desarrollo local +cd backend && npm install && npm run dev +``` + +### URLs de Desarrollo + +| Servicio | URL | +|----------|-----| +| Backend API | http://localhost:3000 | +| Frontend | http://localhost:5173 | +| Adminer (DB) | http://localhost:8080 | +| Mailhog | http://localhost:8025 | + +--- + +## Arquitectura + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Budgets │ │Progress │ │Estimates│ │ HSE │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +└───────┼────────────┼────────────┼────────────┼──────────┘ + │ │ │ │ +┌───────▼────────────▼────────────▼────────────▼──────────┐ +│ Backend (Express + TypeORM) │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Auth Middleware (JWT + RLS) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Budgets │ │Progress │ │Estimates│ │ Auth │ │ +│ │ Service │ │ Service │ │ Service │ │ Service │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +└───────┼────────────┼────────────┼────────────┼──────────┘ + │ │ │ │ +┌───────▼────────────▼────────────▼────────────▼──────────┐ +│ PostgreSQL 15 + PostGIS │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Row Level Security (tenant_id) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────┐ │ +│ │ auth │ │constru.│ │ hr │ │ hse │ │estim.│ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ └──────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### Stack Tecnologico + +| Capa | Tecnologia | +|------|------------| +| **Backend** | Node.js 20, Express 4, TypeORM 0.3 | +| **Frontend** | React 18, Vite 5, TypeScript 5 | +| **Database** | PostgreSQL 15 + PostGIS | +| **Cache** | Redis 7 | +| **Auth** | JWT + Refresh Tokens | +| **Multi-tenant** | RLS (Row Level Security) | --- @@ -21,91 +99,230 @@ Vertical especializada del ERP Suite para empresas de construccion con integraci ``` construccion/ -+-- backend/ # Extensiones backend especificas -| +-- src/ -| +-- server.ts -| +-- shared/database/ -+-- frontend/ -| +-- web/ # App web de gestion (React + Vite) -| +-- mobile/ # App movil para campo (React Native) -+-- database/ # DDL y migrations especificos -| +-- ddl/ -| +-- scripts/ -+-- docs/ # Documentacion completa (407+ archivos) -| +-- 00-overview/ # Vision general -| +-- 01-analisis-referencias/ # Mapeo a erp-core -| +-- 02-definicion-modulos/ # 18 modulos MAI/MAE/MAA -| +-- 03-requerimientos/ # Indice consolidado RF (79) -| +-- 04-modelado/ # Domain models + DDL -| +-- 05-user-stories/ # Indice consolidado US (139) -| +-- 06-frontend-specs/ # Especificaciones UI -| +-- 06-test-plans/ # Planes de prueba -| +-- 07-devops/ # DevOps y deployment -| +-- 08-epicas/ # Epicas consolidadas -| +-- 90-transversal/ # Documentacion cruzada -| +-- 97-adr/ # 12 ADRs -+-- orchestration/ # Sistema de agentes NEXUS - +-- 00-guidelines/ - +-- directivas/ - +-- prompts/ - +-- trazas/ - +-- estados/ +├── backend/ +│ └── src/ +│ ├── modules/ +│ │ ├── auth/ # Autenticacion JWT +│ │ ├── budgets/ # MAI-003 Presupuestos +│ │ ├── progress/ # MAI-005 Control de Obra +│ │ ├── estimates/ # MAI-008 Estimaciones +│ │ ├── construction/ # MAI-002 Proyectos +│ │ ├── hr/ # MAI-007 RRHH +│ │ ├── hse/ # MAA-017 HSE +│ │ └── core/ # Entidades base +│ └── shared/ +│ ├── constants/ # SSOT (schemas, rutas, enums) +│ └── services/ # BaseService multi-tenant +├── frontend/ +│ ├── web/ # App web React +│ └── mobile/ # App movil (futuro) +├── database/ +│ └── schemas/ # DDL por schema +│ ├── 01-construction-schema-ddl.sql # 24 tablas +│ ├── 02-hr-schema-ddl.sql # 8 tablas +│ ├── 03-hse-schema-ddl.sql # 58 tablas +│ ├── 04-estimates-schema-ddl.sql # 8 tablas +│ ├── 05-infonavit-schema-ddl.sql # 8 tablas +│ ├── 06-inventory-ext-schema-ddl.sql # 4 tablas +│ └── 07-purchase-ext-schema-ddl.sql # 5 tablas +├── docs/ # Documentacion completa +├── devops/ +│ └── scripts/ # Validacion SSOT +├── docker-compose.yml +└── .env.example ``` --- -## Modulos por Fase +## Modulos Implementados -### Fase 1: Alcance Inicial (14 modulos, ~670 SP) +### MAI-003: Presupuestos y Costos ✅ -| Codigo | Modulo | RF | US | Estado | -|--------|--------|---:|---:|--------| -| MAI-001 | Fundamentos y Seguridad | 3 | 8 | Documentado | -| MAI-002 | Proyectos y Estructura | 4 | 9 | Documentado | -| MAI-003 | Presupuestos y Costos | 4 | 8 | Documentado | -| MAI-004 | Compras e Inventarios | 4 | 8 | Documentado | -| MAI-005 | Control de Obra | 4 | 8 | Documentado | -| MAI-006 | Reportes y Analytics | 4 | 8 | Documentado | -| MAI-007 | RRHH y Asistencias | 6 | 8 | Documentado | -| MAI-008 | Estimaciones y Facturacion | 5 | 8 | Documentado | -| MAI-009 | Calidad y Postventa | 5 | 8 | Documentado | -| MAI-010 | CRM Derechohabientes | 5 | 8 | Documentado | -| MAI-011 | INFONAVIT | 5 | 8 | Documentado | -| MAI-012 | Contratos y Subcontratos | 5 | 8 | Documentado | -| MAI-013 | Administracion | 5 | 8 | Documentado | -| MAI-018 | Preconstruccion | 5 | 8 | Documentado | +```typescript +// Entidades +- Concepto // Catalogo jerarquico de conceptos +- Presupuesto // Presupuestos versionados +- PresupuestoPartida // Lineas con calculo automatico -### Fase 2: Enterprise (3 modulos, 210 SP) +// Services +- ConceptoService // Arbol, busqueda +- PresupuestoService // CRUD, versionamiento, aprobacion +``` -| Codigo | Modulo | RF | US | Estado | -|--------|--------|---:|---:|--------| -| MAE-014 | Finanzas y Controlling | 5 | 11 | Documentado | -| MAE-015 | Activos y Maquinaria | 5 | 8 | Documentado | -| MAE-016 | Gestion Documental (DMS) | 5 | 7 | Documentado | +### MAI-005: Control de Obra ✅ -### Fase 3: Avanzada +```typescript +// Entidades +- AvanceObra // Avances fisicos con workflow +- FotoAvance // Evidencias con GPS +- BitacoraObra // Bitacora diaria +- ProgramaObra // Programa maestro +- ProgramaActividad // WBS/Actividades -| Codigo | Modulo | Estado | -|--------|--------|--------| -| MAA-017 | Seguridad HSE | Por documentar | +// Services +- AvanceObraService // Workflow captura->revision->aprobacion +- BitacoraObraService // Entradas secuenciales +``` + +### MAI-008: Estimaciones ✅ + +```typescript +// Entidades +- Estimacion // Estimaciones periodicas +- EstimacionConcepto // Lineas con acumulados +- Generador // Numeros generadores +- Anticipo // Anticipos con amortizacion +- Amortizacion // Descuentos por estimacion +- Retencion // Fondo de garantia +- FondoGarantia // Acumulado por contrato +- EstimacionWorkflow // Historial de estados + +// Services +- EstimacionService // Workflow completo, calculo totales +``` + +### Auth: Autenticacion JWT ✅ + +```typescript +// Funcionalidades +- Login con email/password +- Registro de usuarios +- Refresh tokens +- Logout (revocacion) +- Middleware de autorizacion por roles +- Configuracion RLS por tenant +``` --- -## Schemas de Base de Datos +## API Endpoints -| Schema | Descripcion | -|--------|-------------| -| `project_management` | Proyectos, desarrollos, fases, viviendas | -| `financial_management` | Presupuestos, partidas, estimaciones | -| `purchasing_management` | Compras, proveedores, inventarios | -| `construction_management` | Avances, recursos, materiales | -| `quality_management` | Inspecciones, pruebas, no conformidades | -| `infonavit_management` | Integracion INFONAVIT | -| `hr_management` | Personal, cuadrillas, asistencias | -| `crm_management` | Prospectos, derechohabientes | -| `contract_management` | Contratos, subcontratos | -| `assets_management` | Activos, maquinaria, mantenimiento | -| `documents_management` | DMS, versionado, workflows | +### Autenticacion + +```http +POST /api/v1/auth/login +POST /api/v1/auth/register +POST /api/v1/auth/refresh +POST /api/v1/auth/logout +POST /api/v1/auth/change-password +``` + +### Presupuestos + +```http +GET /api/v1/conceptos +GET /api/v1/conceptos/:id +POST /api/v1/conceptos +GET /api/v1/conceptos/tree + +GET /api/v1/presupuestos +GET /api/v1/presupuestos/:id +POST /api/v1/presupuestos +POST /api/v1/presupuestos/:id/partidas +POST /api/v1/presupuestos/:id/approve +POST /api/v1/presupuestos/:id/version +``` + +### Control de Obra + +```http +GET /api/v1/avances +GET /api/v1/avances/:id +POST /api/v1/avances +POST /api/v1/avances/:id/fotos +POST /api/v1/avances/:id/review +POST /api/v1/avances/:id/approve + +GET /api/v1/bitacora/:fraccionamientoId +POST /api/v1/bitacora +``` + +### Estimaciones + +```http +GET /api/v1/estimaciones +GET /api/v1/estimaciones/:id +POST /api/v1/estimaciones +POST /api/v1/estimaciones/:id/conceptos +POST /api/v1/estimaciones/:id/submit +POST /api/v1/estimaciones/:id/review +POST /api/v1/estimaciones/:id/approve +``` + +--- + +## Base de Datos + +### Schemas (7 total, 110 tablas) + +| Schema | Tablas | Descripcion | +|--------|--------|-------------| +| `auth` | 10 | Usuarios, roles, permisos, tenants | +| `construction` | 24 | Proyectos, lotes, presupuestos, avances | +| `hr` | 8 | Empleados, asistencias, cuadrillas | +| `hse` | 58 | Incidentes, capacitaciones, EPP, STPS | +| `estimates` | 8 | Estimaciones, anticipos, retenciones | +| `infonavit` | 8 | Registro RUV, derechohabientes | +| `inventory` | 4 | Almacenes, requisiciones | + +### Row Level Security + +```sql +-- Todas las tablas tienen RLS activado +ALTER TABLE construction.fraccionamientos ENABLE ROW LEVEL SECURITY; + +-- Politica de aislamiento por tenant +CREATE POLICY tenant_isolation ON construction.fraccionamientos + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID); +``` + +--- + +## Scripts NPM + +```bash +# Backend +npm run dev # Desarrollo con hot-reload +npm run build # Compilar TypeScript +npm run start # Produccion +npm run lint # ESLint +npm run test # Jest tests +npm run validate:constants # Validar SSOT +npm run sync:enums # Sincronizar enums a frontend + +# Docker +docker-compose up -d # Levantar servicios +docker-compose --profile dev up # Con Adminer y Mailhog +docker-compose down # Detener servicios +``` + +--- + +## Variables de Entorno + +```bash +# Application +NODE_ENV=development +APP_PORT=3000 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=construccion +DB_PASSWORD=construccion_dev_2024 +DB_NAME=erp_construccion + +# JWT +JWT_SECRET=your-secret-key-min-32-chars +JWT_EXPIRES_IN=1d +JWT_REFRESH_EXPIRES_IN=7d + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +``` + +Ver `.env.example` para la lista completa. --- @@ -113,57 +330,35 @@ construccion/ | Documento | Ubicacion | |-----------|-----------| -| **Indice principal** | `docs/README.md` | -| **Requerimientos (79 RF)** | `docs/03-requerimientos/README.md` | -| **User Stories (139 US)** | `docs/05-user-stories/README.md` | -| **Modulos (18)** | `docs/02-definicion-modulos/_MAP.md` | -| **ADRs (12)** | `docs/97-adr/README.md` | -| **Contexto proyecto** | `orchestration/00-guidelines/CONTEXTO-PROYECTO.md` | -| **Proxima accion** | `orchestration/PROXIMA-ACCION.md` | -| **Schemas SQL** | `docs/04-modelado/database-design/schemas/` | +| Estado del Proyecto | `PROJECT-STATUS.md` | +| Mapa de Base de Datos | `database/_MAP.md` | +| Constantes SSOT | `backend/src/shared/constants/` | +| Modulos (18) | `docs/02-definicion-modulos/` | +| Requerimientos (87 RF) | `docs/03-requerimientos/` | +| User Stories (149 US) | `docs/05-user-stories/` | +| ADRs (12) | `docs/97-adr/` | --- -## Reutilizacion de ERP Core +## Proximos Pasos -| Capa | Reutilizacion | -|------|---------------| -| Infraestructura (Auth, RLS, RBAC) | 90% | -| Backend (Patrones, Servicios) | 60-80% | -| Frontend (UI, Hooks, Stores) | 50-70% | -| Database (Schemas, Funciones) | 70% | -| **Total** | **61%** | +1. **Corto Plazo** + - Controllers REST para modulos nuevos + - Backend MAI-009 (Calidad y Postventa) + - Backend MAI-011 (INFONAVIT) + - Testing de modulos existentes + +2. **Mediano Plazo** + - Frontend: Integracion con API + - Modulos de Presupuestos y Estimaciones + - Curva S y reportes de avance --- -## Comandos Utiles +## Licencia -```bash -# Ver documentacion -ls docs/ - -# Ver modulos -ls docs/02-definicion-modulos/ - -# Contar archivos -find docs/ -name "*.md" | wc -l # ~407 archivos - -# Ver indice de RF -cat docs/03-requerimientos/README.md - -# Ver indice de US -cat docs/05-user-stories/README.md -``` +UNLICENSED - Proyecto privado --- -## Dependencias - -- **Requiere:** erp-core (auth, users, tenants, catalogs) -- **Extiende:** Schemas y modulos base de erp-core -- **Stack:** Node.js, Express, TypeORM, React, Vite, PostgreSQL - ---- - -*Proyecto parte de ERP Suite - Fabrica de Software con Agentes IA* -**Ultima actualizacion:** 2025-12-05 +**Ultima actualizacion:** 2025-12-12 diff --git a/projects/erp-suite/apps/verticales/construccion/backend/Dockerfile b/projects/erp-suite/apps/verticales/construccion/backend/Dockerfile new file mode 100644 index 0000000..06d0e44 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/Dockerfile @@ -0,0 +1,84 @@ +# ============================================================================= +# Dockerfile - Backend API +# ERP Construccion - Node.js + Express + TypeScript +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Base +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS base + +# Install dependencies for native modules +RUN apk add --no-cache \ + python3 \ + make \ + g++ \ + curl + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# ----------------------------------------------------------------------------- +# Stage 2: Development +# ----------------------------------------------------------------------------- +FROM base AS development + +# Install all dependencies (including devDependencies) +RUN npm ci + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3000 + +# Development command with hot reload +CMD ["npm", "run", "dev"] + +# ----------------------------------------------------------------------------- +# Stage 3: Builder +# ----------------------------------------------------------------------------- +FROM base AS builder + +# Install all dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build TypeScript +RUN npm run build + +# Prune devDependencies +RUN npm prune --production + +# ----------------------------------------------------------------------------- +# Stage 4: Production +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS production + +# Security: Run as non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +WORKDIR /app + +# Copy built application +COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ + +# Set user +USER nodejs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# Production command +CMD ["node", "dist/server.js"] diff --git a/projects/erp-suite/apps/verticales/construccion/backend/README.md b/projects/erp-suite/apps/verticales/construccion/backend/README.md index 0c295de..0e5bb19 100644 --- a/projects/erp-suite/apps/verticales/construccion/backend/README.md +++ b/projects/erp-suite/apps/verticales/construccion/backend/README.md @@ -1,243 +1,441 @@ -# Backend - MVP Sistema Administración de Obra +# Backend - ERP Construccion -**Stack:** Node.js + Express + TypeScript + TypeORM -**Versión:** 1.0.0 -**Fecha:** 2025-11-20 +API REST para sistema de administracion de obra e INFONAVIT. + +| Campo | Valor | +|-------|-------| +| **Stack** | Node.js 20 + Express 4 + TypeScript 5 + TypeORM 0.3 | +| **Version** | 1.0.0 | +| **Entidades** | 30 | +| **Services** | 8 | +| **Arquitectura** | Multi-tenant con RLS | --- -## 📋 DESCRIPCIÓN +## Quick Start -API REST del sistema de administración de obra e INFONAVIT. +```bash +# Instalar dependencias +npm install -**Arquitectura:** Modular basada en dominios (DDD light) -**Base de datos:** PostgreSQL 15+ con PostGIS -**Autenticación:** JWT +# Configurar variables de entorno +cp ../.env.example .env + +# Desarrollo con hot-reload +npm run dev + +# El servidor estara en http://localhost:3000 +``` --- -## 🏗️ ESTRUCTURA +## Estructura del Proyecto ``` src/ -├── shared/ # Código compartido -│ ├── config/ # Configuraciones -│ ├── constants/ # Constantes globales -│ ├── database/ # Configuración TypeORM -│ ├── types/ # Tipos TypeScript compartidos -│ ├── utils/ # Utilidades -│ └── middleware/ # Middlewares de Express -└── modules/ # Módulos de negocio - ├── auth/ # Autenticación y autorización - │ ├── entities/ # Entities TypeORM - │ ├── services/ # Lógica de negocio - │ ├── controllers/ # Controladores Express - │ ├── dto/ # DTOs de validación - │ └── auth.module.ts # Módulo de NestJS style - ├── projects/ # Gestión de proyectos - ├── budgets/ # Presupuestos y control de costos - └── [otros módulos]/ +├── modules/ +│ ├── auth/ # Autenticacion JWT +│ │ ├── dto/ +│ │ │ └── auth.dto.ts # DTOs tipados +│ │ ├── middleware/ +│ │ │ └── auth.middleware.ts +│ │ ├── services/ +│ │ │ └── auth.service.ts +│ │ └── index.ts +│ │ +│ ├── budgets/ # MAI-003 Presupuestos +│ │ ├── entities/ +│ │ │ ├── concepto.entity.ts +│ │ │ ├── presupuesto.entity.ts +│ │ │ └── presupuesto-partida.entity.ts +│ │ ├── services/ +│ │ │ ├── concepto.service.ts +│ │ │ └── presupuesto.service.ts +│ │ └── index.ts +│ │ +│ ├── progress/ # MAI-005 Control de Obra +│ │ ├── entities/ +│ │ │ ├── avance-obra.entity.ts +│ │ │ ├── foto-avance.entity.ts +│ │ │ ├── bitacora-obra.entity.ts +│ │ │ ├── programa-obra.entity.ts +│ │ │ └── programa-actividad.entity.ts +│ │ ├── services/ +│ │ │ ├── avance-obra.service.ts +│ │ │ └── bitacora-obra.service.ts +│ │ └── index.ts +│ │ +│ ├── estimates/ # MAI-008 Estimaciones +│ │ ├── entities/ +│ │ │ ├── estimacion.entity.ts +│ │ │ ├── estimacion-concepto.entity.ts +│ │ │ ├── generador.entity.ts +│ │ │ ├── anticipo.entity.ts +│ │ │ ├── amortizacion.entity.ts +│ │ │ ├── retencion.entity.ts +│ │ │ ├── fondo-garantia.entity.ts +│ │ │ └── estimacion-workflow.entity.ts +│ │ ├── services/ +│ │ │ └── estimacion.service.ts +│ │ └── index.ts +│ │ +│ ├── construction/ # MAI-002 Proyectos +│ │ └── entities/ +│ │ ├── proyecto.entity.ts +│ │ └── fraccionamiento.entity.ts +│ │ +│ ├── hr/ # MAI-007 RRHH +│ │ └── entities/ +│ │ ├── employee.entity.ts +│ │ ├── puesto.entity.ts +│ │ └── employee-fraccionamiento.entity.ts +│ │ +│ ├── hse/ # MAA-017 Seguridad HSE +│ │ └── entities/ +│ │ ├── incidente.entity.ts +│ │ ├── incidente-involucrado.entity.ts +│ │ ├── incidente-accion.entity.ts +│ │ └── capacitacion.entity.ts +│ │ +│ └── core/ # Entidades base +│ └── entities/ +│ ├── user.entity.ts +│ └── tenant.entity.ts +│ +└── shared/ + ├── constants/ # SSOT + │ ├── database.constants.ts + │ ├── api.constants.ts + │ ├── enums.constants.ts + │ └── index.ts + ├── services/ + │ └── base.service.ts # CRUD multi-tenant + └── database/ + └── typeorm.config.ts ``` --- -## 🚀 SETUP INICIAL +## Modulos Implementados -### 1. Instalar Dependencias +### Auth Module -```bash -npm install -``` +Autenticacion JWT con refresh tokens y multi-tenancy. -### 2. Configurar Variables de Entorno - -```bash -cp .env.example .env -# Editar .env con tus valores -``` - -### 3. Ejecutar Migraciones (si existen) - -```bash -npm run migration:run -``` - -### 4. Iniciar Servidor de Desarrollo - -```bash -npm run dev -``` - -El servidor estará disponible en `http://localhost:3000` - ---- - -## 📝 SCRIPTS DISPONIBLES - -| Script | Descripción | -|--------|-------------| -| `npm run dev` | Inicia servidor en modo desarrollo (hot reload) | -| `npm run build` | Compila TypeScript a JavaScript | -| `npm start` | Inicia servidor en producción (requiere build) | -| `npm run lint` | Ejecuta linter (ESLint) | -| `npm run lint:fix` | Ejecuta linter y corrige automáticamente | -| `npm test` | Ejecuta tests con Jest | -| `npm run test:watch` | Ejecuta tests en modo watch | -| `npm run test:coverage` | Ejecuta tests con cobertura | -| `npm run migration:generate` | Genera nueva migración | -| `npm run migration:run` | Ejecuta migraciones pendientes | -| `npm run migration:revert` | Revierte última migración | - ---- - -## 🔧 CONFIGURACIÓN - -### TypeORM - -Configuración en `src/shared/database/typeorm.config.ts` - -**Variables importantes:** -- `DATABASE_URL` - URL completa de conexión -- `DB_SYNCHRONIZE` - ⚠️ Siempre `false` en producción -- `DB_LOGGING` - Logs de queries SQL - -### Path Aliases - -Configurados en `tsconfig.json`: ```typescript -import { User } from '@modules/auth/entities/user.entity'; -import { config } from '@config/database.config'; -import { formatDate } from '@utils/date.utils'; +// Services +AuthService + ├── login(dto) // Login con email/password + ├── register(dto) // Registro de usuarios + ├── refresh(dto) // Renovar tokens + ├── logout(token) // Revocar refresh token + └── changePassword(dto) // Cambiar password + +// Middleware +AuthMiddleware + ├── authenticate // Validar JWT (requerido) + ├── optionalAuthenticate // Validar JWT (opcional) + ├── authorize(...roles) // Autorizar por roles + ├── requireAdmin // Solo admin/super_admin + └── requireSupervisor // Solo supervisores+ +``` + +### Budgets Module (MAI-003) + +Catalogo de conceptos y presupuestos de obra. + +```typescript +// Entities +Concepto // Catalogo jerarquico (arbol) +Presupuesto // Presupuestos versionados +PresupuestoPartida // Lineas con calculo automatico + +// Services +ConceptoService + ├── createConcepto(ctx, dto) // Crear con nivel/path automatico + ├── findRootConceptos(ctx) // Conceptos raiz + ├── findChildren(ctx, parentId) // Hijos de un concepto + ├── getConceptoTree(ctx, rootId) // Arbol completo + └── search(ctx, term) // Busqueda por codigo/nombre + +PresupuestoService + ├── createPresupuesto(ctx, dto) + ├── findByFraccionamiento(ctx, id) + ├── findWithPartidas(ctx, id) + ├── addPartida(ctx, id, dto) + ├── updatePartida(ctx, id, dto) + ├── removePartida(ctx, id) + ├── recalculateTotal(ctx, id) + ├── createNewVersion(ctx, id) // Versionamiento + └── approve(ctx, id) +``` + +### Progress Module (MAI-005) + +Control de avances fisicos y bitacora de obra. + +```typescript +// Entities +AvanceObra // Avances con workflow +FotoAvance // Evidencias fotograficas con GPS +BitacoraObra // Bitacora diaria +ProgramaObra // Programa maestro +ProgramaActividad // Actividades WBS + +// Services +AvanceObraService + ├── createAvance(ctx, dto) + ├── findByLote(ctx, loteId) + ├── findByDepartamento(ctx, deptoId) + ├── findWithFilters(ctx, filters) + ├── findWithFotos(ctx, id) + ├── addFoto(ctx, id, dto) + ├── review(ctx, id) // Workflow: revisar + ├── approve(ctx, id) // Workflow: aprobar + ├── reject(ctx, id, reason) // Workflow: rechazar + └── getAccumulatedProgress(ctx) // Acumulado por concepto + +BitacoraObraService + ├── createEntry(ctx, dto) // Numero automatico + ├── findByFraccionamiento(ctx, id) + ├── findWithFilters(ctx, id, filters) + ├── findByDate(ctx, id, date) + ├── findLatest(ctx, id) + └── getStats(ctx, id) // Estadisticas +``` + +### Estimates Module (MAI-008) + +Estimaciones periodicas con workflow de aprobacion. + +```typescript +// Entities +Estimacion // Estimaciones con workflow +EstimacionConcepto // Lineas con acumulados +Generador // Numeros generadores +Anticipo // Anticipos +Amortizacion // Amortizaciones +Retencion // Retenciones +FondoGarantia // Fondo de garantia +EstimacionWorkflow // Historial de estados + +// Services +EstimacionService + ├── createEstimacion(ctx, dto) // Numero automatico + ├── findByContrato(ctx, contratoId) + ├── findWithFilters(ctx, filters) + ├── findWithDetails(ctx, id) // Con relaciones + ├── addConcepto(ctx, id, dto) + ├── addGenerador(ctx, conceptoId, dto) + ├── recalculateTotals(ctx, id) // Llama funcion PG + ├── submit(ctx, id) // Workflow + ├── review(ctx, id) // Workflow + ├── approve(ctx, id) // Workflow + ├── reject(ctx, id, reason) // Workflow + └── getContractSummary(ctx, id) // Resumen financiero ``` --- -## 📊 CONVENCIONES +## Base Service + +Servicio base con CRUD multi-tenant. + +```typescript +// Uso +class MiService extends BaseService { + constructor(repository: Repository) { + super(repository); + } +} + +// Metodos disponibles +BaseService + ├── findAll(ctx, options?) // Paginado + ├── findById(ctx, id) + ├── findOne(ctx, where) + ├── find(ctx, options) + ├── create(ctx, data) + ├── update(ctx, id, data) + ├── softDelete(ctx, id) + ├── hardDelete(ctx, id) + ├── count(ctx, where?) + └── exists(ctx, where) + +// ServiceContext +interface ServiceContext { + tenantId: string; + userId: string; +} +``` + +--- + +## SSOT Constants + +Sistema de constantes centralizadas. + +```typescript +// database.constants.ts +import { DB_SCHEMAS, DB_TABLES, TABLE_REFS } from '@shared/constants'; + +DB_SCHEMAS.CONSTRUCTION // 'construction' +DB_TABLES.construction.CONCEPTOS // 'conceptos' +TABLE_REFS.FRACCIONAMIENTOS // 'construction.fraccionamientos' + +// api.constants.ts +import { API_ROUTES } from '@shared/constants'; + +API_ROUTES.PRESUPUESTOS.BASE // '/api/v1/presupuestos' +API_ROUTES.ESTIMACIONES.BY_ID(id) // '/api/v1/estimaciones/:id' + +// enums.constants.ts +import { ROLES, PROJECT_STATUS } from '@shared/constants'; + +ROLES.ADMIN // 'admin' +PROJECT_STATUS.IN_PROGRESS // 'in_progress' +``` + +--- + +## Scripts NPM + +```bash +# Desarrollo +npm run dev # Hot-reload con ts-node-dev +npm run build # Compilar TypeScript +npm run start # Produccion (dist/) + +# Calidad +npm run lint # ESLint +npm run lint:fix # ESLint con autofix +npm run test # Jest +npm run test:watch # Jest watch mode +npm run test:coverage # Jest con cobertura + +# Base de datos +npm run migration:generate # Generar migracion +npm run migration:run # Ejecutar migraciones +npm run migration:revert # Revertir ultima + +# SSOT +npm run validate:constants # Validar no hardcoding +npm run sync:enums # Sincronizar a frontend +npm run precommit # lint + validate +``` + +--- + +## Convenciones ### Nomenclatura -Seguir **ESTANDARES-NOMENCLATURA.md**: -- Archivos: `kebab-case.tipo.ts` -- Clases: `PascalCase` + sufijo (Entity, Service, Controller, Dto) -- Variables: `camelCase` -- Constantes: `UPPER_SNAKE_CASE` -- Métodos: `camelCase` con verbo al inicio +| Tipo | Convencion | Ejemplo | +|------|------------|---------| +| Archivos | kebab-case.tipo.ts | `concepto.entity.ts` | +| Clases | PascalCase + sufijo | `ConceptoService` | +| Variables | camelCase | `totalAmount` | +| Constantes | UPPER_SNAKE_CASE | `DB_SCHEMAS` | +| Metodos | camelCase + verbo | `findByContrato` | -### Estructura de Entity +### Entity Pattern ```typescript -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; - -@Entity({ schema: 'project_management', name: 'projects' }) -export class ProjectEntity { +@Entity({ schema: 'construction', name: 'conceptos' }) +@Index(['tenantId', 'code'], { unique: true }) +export class Concepto { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'varchar', length: 50, unique: true }) - code: string; + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; - @Column({ type: 'timestamptz', default: () => 'NOW()' }) - createdAt: Date; + // ... columnas con name: 'snake_case' + + // Soft delete + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; } ``` -### Estructura de Service +### Service Pattern ```typescript -import { Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; - -@Injectable() -export class ProjectService { +export class MiService extends BaseService { constructor( - @InjectRepository(ProjectEntity) - private projectRepo: Repository, - ) {} - - async findAll(): Promise { - return await this.projectRepo.find(); - } -} -``` - -### Estructura de Controller - -```typescript -import { Controller, Get, Post, Body } from '@nestjs/common'; - -@Controller('api/v1/projects') -export class ProjectController { - constructor(private projectService: ProjectService) {} - - @Get() - async findAll() { - return await this.projectService.findAll(); + repository: Repository, + private readonly otroRepo: Repository + ) { + super(repository); } - @Post() - async create(@Body() dto: CreateProjectDto) { - return await this.projectService.create(dto); + async miMetodo(ctx: ServiceContext, data: MiDto): Promise { + // ctx tiene tenantId y userId + return this.create(ctx, data); } } ``` --- -## 🔍 TESTING +## Seguridad -### Ejecutar Tests +- Helmet para HTTP security headers +- CORS configurado por dominio +- Rate limiting por IP +- JWT con refresh tokens +- Bcrypt (12 rounds) para passwords +- class-validator para inputs +- RLS para aislamiento de tenants + +--- + +## Testing ```bash -npm test # Todos los tests -npm run test:watch # Modo watch -npm run test:coverage # Con cobertura +# Ejecutar tests +npm test + +# Con cobertura +npm run test:coverage + +# Watch mode +npm run test:watch ``` -### Estructura de Tests - ```typescript -describe('ProjectService', () => { - let service: ProjectService; - let mockRepo: jest.Mocked>; +// Ejemplo de test +describe('ConceptoService', () => { + let service: ConceptoService; + let mockRepo: jest.Mocked>; beforeEach(() => { mockRepo = createMockRepository(); - service = new ProjectService(mockRepo); + service = new ConceptoService(mockRepo); }); - it('should find all projects', async () => { - mockRepo.find.mockResolvedValue([mockProject]); - const result = await service.findAll(); - expect(result).toHaveLength(1); + it('should create concepto with level', async () => { + const ctx = { tenantId: 'uuid', userId: 'uuid' }; + const dto = { code: '001', name: 'Test' }; + + mockRepo.save.mockResolvedValue({ ...dto, level: 0 }); + + const result = await service.createConcepto(ctx, dto); + expect(result.level).toBe(0); }); }); ``` --- -## 📚 REFERENCIAS +## Debugging -- [DIRECTIVA-CALIDAD-CODIGO.md](../../orchestration/directivas/DIRECTIVA-CALIDAD-CODIGO.md) -- [ESTANDARES-NOMENCLATURA.md](../../orchestration/directivas/ESTANDARES-NOMENCLATURA.md) -- [TypeORM Documentation](https://typeorm.io/) -- [Express Documentation](https://expressjs.com/) - ---- - -## 🔐 SEGURIDAD - -- ✅ Helmet para HTTP headers -- ✅ CORS configurado -- ✅ Rate limiting -- ✅ JWT para autenticación -- ✅ Bcrypt para passwords -- ✅ Validación de inputs con class-validator - ---- - -## 🐛 DEBUGGING - -### VS Code Launch Configuration +### VS Code ```json { @@ -246,13 +444,18 @@ describe('ProjectService', () => { "name": "Debug Backend", "runtimeArgs": ["-r", "ts-node/register"], "args": ["${workspaceFolder}/src/server.ts"], - "env": { - "NODE_ENV": "development" - } + "env": { "NODE_ENV": "development" } } ``` +### Logs + +```typescript +// Configurar en .env +LOG_LEVEL=debug +LOG_FORMAT=dev +``` + --- -**Mantenido por:** Backend-Agent -**Última actualización:** 2025-11-20 +**Ultima actualizacion:** 2025-12-12 diff --git a/projects/erp-suite/apps/verticales/construccion/backend/package.json b/projects/erp-suite/apps/verticales/construccion/backend/package.json index ba590e8..f9c111e 100644 --- a/projects/erp-suite/apps/verticales/construccion/backend/package.json +++ b/projects/erp-suite/apps/verticales/construccion/backend/package.json @@ -15,7 +15,10 @@ "typeorm": "typeorm-ts-node-commonjs", "migration:generate": "npm run typeorm -- migration:generate", "migration:run": "npm run typeorm -- migration:run", - "migration:revert": "npm run typeorm -- migration:revert" + "migration:revert": "npm run typeorm -- migration:revert", + "validate:constants": "ts-node ../devops/scripts/validate-constants-usage.ts", + "sync:enums": "ts-node ../devops/scripts/sync-enums.ts", + "precommit": "npm run lint && npm run validate:constants" }, "keywords": [ "construccion", diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/dto/auth.dto.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/dto/auth.dto.ts new file mode 100644 index 0000000..9fd85eb --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/dto/auth.dto.ts @@ -0,0 +1,70 @@ +/** + * Auth DTOs - Data Transfer Objects para autenticación + * + * @module Auth + */ + +export interface LoginDto { + email: string; + password: string; + tenantId?: string; +} + +export interface RegisterDto { + email: string; + password: string; + firstName: string; + lastName: string; + tenantId: string; +} + +export interface RefreshTokenDto { + refreshToken: string; +} + +export interface ChangePasswordDto { + currentPassword: string; + newPassword: string; +} + +export interface ResetPasswordRequestDto { + email: string; +} + +export interface ResetPasswordDto { + token: string; + newPassword: string; +} + +export interface TokenPayload { + sub: string; // userId + email: string; + tenantId: string; + roles: string[]; + type: 'access' | 'refresh'; + iat?: number; + exp?: number; +} + +export interface AuthResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; + user: { + id: string; + email: string; + firstName: string; + lastName: string; + roles: string[]; + }; + tenant: { + id: string; + name: string; + }; +} + +export interface TokenValidationResult { + valid: boolean; + payload?: TokenPayload; + error?: string; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/index.ts new file mode 100644 index 0000000..c720431 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/index.ts @@ -0,0 +1,12 @@ +/** + * Auth Module - Main Exports + * + * Módulo de autenticación con JWT y refresh tokens. + * Implementa multi-tenancy con RLS. + * + * @module Auth + */ + +export * from './dto/auth.dto'; +export * from './services/auth.service'; +export * from './middleware/auth.middleware'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/middleware/auth.middleware.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/middleware/auth.middleware.ts new file mode 100644 index 0000000..e54daed --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/middleware/auth.middleware.ts @@ -0,0 +1,178 @@ +/** + * Auth Middleware - Middleware de Autenticación + * + * Middleware para Express que valida JWT y extrae información del usuario. + * Configura el tenant_id para RLS en PostgreSQL. + * + * @module Auth + */ + +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AuthService } from '../services/auth.service'; +import { TokenPayload } from '../dto/auth.dto'; + +// Extender Request de Express con información de autenticación +declare global { + namespace Express { + interface Request { + user?: TokenPayload; + tenantId?: string; + } + } +} + +export class AuthMiddleware { + constructor( + private readonly authService: AuthService, + private readonly dataSource: DataSource + ) {} + + /** + * Middleware de autenticación requerida + */ + authenticate = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const token = this.extractToken(req); + + if (!token) { + res.status(401).json({ + error: 'Unauthorized', + message: 'No token provided', + }); + return; + } + + const validation = this.authService.validateAccessToken(token); + + if (!validation.valid || !validation.payload) { + res.status(401).json({ + error: 'Unauthorized', + message: validation.error || 'Invalid token', + }); + return; + } + + // Establecer información en el request + req.user = validation.payload; + req.tenantId = validation.payload.tenantId; + + // Configurar tenant_id para RLS en PostgreSQL + await this.setTenantContext(validation.payload.tenantId); + + next(); + } catch (error) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication failed', + }); + } + }; + + /** + * Middleware de autenticación opcional + */ + optionalAuthenticate = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const token = this.extractToken(req); + + if (token) { + const validation = this.authService.validateAccessToken(token); + + if (validation.valid && validation.payload) { + req.user = validation.payload; + req.tenantId = validation.payload.tenantId; + await this.setTenantContext(validation.payload.tenantId); + } + } + + next(); + } catch { + // Si hay error, continuar sin autenticación + next(); + } + }; + + /** + * Middleware de autorización por roles + */ + authorize = (...allowedRoles: string[]) => { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ + error: 'Unauthorized', + message: 'Authentication required', + }); + return; + } + + const hasRole = req.user.roles.some((role) => allowedRoles.includes(role)); + + if (!hasRole) { + res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions', + }); + return; + } + + next(); + }; + }; + + /** + * Middleware que requiere rol de admin + */ + requireAdmin = (req: Request, res: Response, next: NextFunction): void => { + return this.authorize('admin', 'super_admin')(req, res, next); + }; + + /** + * Middleware que requiere ser supervisor + */ + requireSupervisor = (req: Request, res: Response, next: NextFunction): void => { + return this.authorize('admin', 'super_admin', 'supervisor_obra', 'supervisor_hse')(req, res, next); + }; + + /** + * Extraer token del header Authorization + */ + private extractToken(req: Request): string | null { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return null; + } + + // Bearer token + const [type, token] = authHeader.split(' '); + + if (type !== 'Bearer' || !token) { + return null; + } + + return token; + } + + /** + * Configurar contexto de tenant para RLS + */ + private async setTenantContext(tenantId: string): Promise { + try { + await this.dataSource.query(`SET app.current_tenant_id = '${tenantId}'`); + } catch (error) { + console.error('Error setting tenant context:', error); + throw new Error('Failed to set tenant context'); + } + } +} + +/** + * Factory para crear middleware de autenticación + */ +export function createAuthMiddleware( + authService: AuthService, + dataSource: DataSource +): AuthMiddleware { + return new AuthMiddleware(authService, dataSource); +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/auth.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/auth.service.ts new file mode 100644 index 0000000..b9e4ff7 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/auth.service.ts @@ -0,0 +1,370 @@ +/** + * AuthService - Servicio de Autenticación + * + * Gestiona login, logout, refresh tokens y validación de JWT. + * Implementa patrón multi-tenant con verificación de tenant_id. + * + * @module Auth + */ + +import * as jwt from 'jsonwebtoken'; +import * as bcrypt from 'bcryptjs'; +import { Repository } from 'typeorm'; +import { User } from '../../core/entities/user.entity'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { + LoginDto, + RegisterDto, + RefreshTokenDto, + ChangePasswordDto, + TokenPayload, + AuthResponse, + TokenValidationResult, +} from '../dto/auth.dto'; + +export interface RefreshToken { + id: string; + userId: string; + token: string; + expiresAt: Date; + revokedAt?: Date; +} + +export class AuthService { + private readonly jwtSecret: string; + private readonly jwtExpiresIn: string; + private readonly jwtRefreshExpiresIn: string; + + constructor( + private readonly userRepository: Repository, + private readonly tenantRepository: Repository, + private readonly refreshTokenRepository: Repository + ) { + this.jwtSecret = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production-minimum-32-chars'; + this.jwtExpiresIn = process.env.JWT_EXPIRES_IN || '1d'; + this.jwtRefreshExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d'; + } + + /** + * Login de usuario + */ + async login(dto: LoginDto): Promise { + // Buscar usuario por email + const user = await this.userRepository.findOne({ + where: { email: dto.email, deletedAt: null } as any, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user) { + throw new Error('Invalid credentials'); + } + + // Verificar password + const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash); + if (!isPasswordValid) { + throw new Error('Invalid credentials'); + } + + // Verificar que el usuario esté activo + if (!user.isActive) { + throw new Error('User is not active'); + } + + // Obtener tenant + const tenantId = dto.tenantId || user.defaultTenantId; + if (!tenantId) { + throw new Error('No tenant specified'); + } + + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, isActive: true, deletedAt: null } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found or inactive'); + } + + // Obtener roles del usuario + const roles = user.userRoles?.map((ur) => ur.role.code) || []; + + // Generar tokens + const accessToken = this.generateAccessToken(user, tenantId, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + // Actualizar último login + await this.userRepository.update(user.id, { lastLoginAt: new Date() }); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Registro de usuario + */ + async register(dto: RegisterDto): Promise { + // Verificar si el email ya existe + const existingUser = await this.userRepository.findOne({ + where: { email: dto.email } as any, + }); + + if (existingUser) { + throw new Error('Email already registered'); + } + + // Verificar que el tenant existe + const tenant = await this.tenantRepository.findOne({ + where: { id: dto.tenantId, isActive: true } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found'); + } + + // Hash del password + const passwordHash = await bcrypt.hash(dto.password, 12); + + // Crear usuario + const user = await this.userRepository.save( + this.userRepository.create({ + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + defaultTenantId: dto.tenantId, + isActive: true, + }) + ); + + // Generar tokens (rol default: user) + const roles = ['user']; + const accessToken = this.generateAccessToken(user, dto.tenantId, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Refresh de token + */ + async refresh(dto: RefreshTokenDto): Promise { + // Validar refresh token + const validation = this.validateToken(dto.refreshToken, 'refresh'); + if (!validation.valid || !validation.payload) { + throw new Error('Invalid refresh token'); + } + + // Verificar que el token no está revocado + const storedToken = await this.refreshTokenRepository.findOne({ + where: { token: dto.refreshToken, revokedAt: null } as any, + }); + + if (!storedToken || storedToken.expiresAt < new Date()) { + throw new Error('Refresh token expired or revoked'); + } + + // Obtener usuario + const user = await this.userRepository.findOne({ + where: { id: validation.payload.sub, deletedAt: null } as any, + relations: ['userRoles', 'userRoles.role'], + }); + + if (!user || !user.isActive) { + throw new Error('User not found or inactive'); + } + + // Obtener tenant + const tenant = await this.tenantRepository.findOne({ + where: { id: validation.payload.tenantId, isActive: true } as any, + }); + + if (!tenant) { + throw new Error('Tenant not found or inactive'); + } + + const roles = user.userRoles?.map((ur) => ur.role.code) || []; + + // Revocar token anterior + await this.refreshTokenRepository.update(storedToken.id, { revokedAt: new Date() }); + + // Generar nuevos tokens + const accessToken = this.generateAccessToken(user, tenant.id, roles); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + expiresIn: this.getExpiresInSeconds(this.jwtExpiresIn), + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + roles, + }, + tenant: { + id: tenant.id, + name: tenant.name, + }, + }; + } + + /** + * Logout - Revocar refresh token + */ + async logout(refreshToken: string): Promise { + await this.refreshTokenRepository.update( + { token: refreshToken } as any, + { revokedAt: new Date() } + ); + } + + /** + * Cambiar password + */ + async changePassword(userId: string, dto: ChangePasswordDto): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId } as any, + }); + + if (!user) { + throw new Error('User not found'); + } + + const isCurrentValid = await bcrypt.compare(dto.currentPassword, user.passwordHash); + if (!isCurrentValid) { + throw new Error('Current password is incorrect'); + } + + const newPasswordHash = await bcrypt.hash(dto.newPassword, 12); + await this.userRepository.update(userId, { passwordHash: newPasswordHash }); + + // Revocar todos los refresh tokens del usuario + await this.refreshTokenRepository.update( + { userId } as any, + { revokedAt: new Date() } + ); + } + + /** + * Validar access token + */ + validateAccessToken(token: string): TokenValidationResult { + return this.validateToken(token, 'access'); + } + + /** + * Validar token + */ + private validateToken(token: string, expectedType: 'access' | 'refresh'): TokenValidationResult { + try { + const payload = jwt.verify(token, this.jwtSecret) as TokenPayload; + + if (payload.type !== expectedType) { + return { valid: false, error: 'Invalid token type' }; + } + + return { valid: true, payload }; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + return { valid: false, error: 'Token expired' }; + } + if (error instanceof jwt.JsonWebTokenError) { + return { valid: false, error: 'Invalid token' }; + } + return { valid: false, error: 'Token validation failed' }; + } + } + + /** + * Generar access token + */ + private generateAccessToken(user: User, tenantId: string, roles: string[]): string { + const payload: TokenPayload = { + sub: user.id, + email: user.email, + tenantId, + roles, + type: 'access', + }; + + return jwt.sign(payload, this.jwtSecret, { + expiresIn: this.jwtExpiresIn, + }); + } + + /** + * Generar refresh token + */ + private async generateRefreshToken(userId: string): Promise { + const payload: Partial = { + sub: userId, + type: 'refresh', + }; + + const token = jwt.sign(payload, this.jwtSecret, { + expiresIn: this.jwtRefreshExpiresIn, + }); + + // Almacenar en DB + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 días + + await this.refreshTokenRepository.save( + this.refreshTokenRepository.create({ + userId, + token, + expiresAt, + }) + ); + + return token; + } + + /** + * Convertir expiresIn a segundos + */ + private getExpiresInSeconds(expiresIn: string): number { + const match = expiresIn.match(/^(\d+)([dhms])$/); + if (!match) return 86400; // default 1 día + + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case 'd': return value * 86400; + case 'h': return value * 3600; + case 'm': return value * 60; + case 's': return value; + default: return 86400; + } + } +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/index.ts new file mode 100644 index 0000000..7255de3 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/auth/services/index.ts @@ -0,0 +1,5 @@ +/** + * Auth Module - Service Exports + */ + +export * from './auth.service'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/concepto.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/concepto.entity.ts new file mode 100644 index 0000000..da83fc8 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/concepto.entity.ts @@ -0,0 +1,100 @@ +/** + * Concepto Entity + * Catalogo de conceptos de obra (estructura jerarquica) + * + * @module Budgets + * @table construction.conceptos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'construction', name: 'conceptos' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId']) +@Index(['parentId']) +@Index(['code']) +export class Concepto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @Column({ type: 'varchar', length: 50 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'unit_id', type: 'uuid', nullable: true }) + unitId: string | null; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, nullable: true }) + unitPrice: number | null; + + @Column({ name: 'is_composite', type: 'boolean', default: false }) + isComposite: boolean; + + @Column({ type: 'integer', default: 0 }) + level: number; + + @Column({ type: 'varchar', length: 500, nullable: true }) + path: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Concepto, (c) => c.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: Concepto | null; + + @OneToMany(() => Concepto, (c) => c.parent) + children: Concepto[]; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'updated_by' }) + updatedBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/index.ts new file mode 100644 index 0000000..94a23af --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/index.ts @@ -0,0 +1,8 @@ +/** + * Budgets Module - Entity Exports + * MAI-003: Presupuestos + */ + +export * from './concepto.entity'; +export * from './presupuesto.entity'; +export * from './presupuesto-partida.entity'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto-partida.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto-partida.entity.ts new file mode 100644 index 0000000..aa926fb --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto-partida.entity.ts @@ -0,0 +1,95 @@ +/** + * PresupuestoPartida Entity + * Lineas/partidas de un presupuesto + * + * @module Budgets + * @table construction.presupuesto_partidas + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Presupuesto } from './presupuesto.entity'; +import { Concepto } from './concepto.entity'; + +@Entity({ schema: 'construction', name: 'presupuesto_partidas' }) +@Index(['presupuestoId', 'conceptoId'], { unique: true }) +@Index(['tenantId']) +export class PresupuestoPartida { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'presupuesto_id', type: 'uuid' }) + presupuestoId: string; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ type: 'integer', default: 0 }) + sequence: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 }) + unitPrice: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'total_amount', + type: 'decimal', + precision: 14, + scale: 2, + insert: false, + update: false, + }) + totalAmount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Presupuesto, (p) => p.partidas) + @JoinColumn({ name: 'presupuesto_id' }) + presupuesto: Presupuesto; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto.entity.ts new file mode 100644 index 0000000..a4da148 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/entities/presupuesto.entity.ts @@ -0,0 +1,107 @@ +/** + * Presupuesto Entity + * Presupuestos de obra por prototipo o fraccionamiento + * + * @module Budgets + * @table construction.presupuestos + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { PresupuestoPartida } from './presupuesto-partida.entity'; + +@Entity({ schema: 'construction', name: 'presupuestos' }) +@Index(['tenantId', 'code', 'version'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class Presupuesto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid', nullable: true }) + fraccionamientoId: string | null; + + @Column({ name: 'prototipo_id', type: 'uuid', nullable: true }) + prototipoId: string | null; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'integer', default: 1 }) + version: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + totalAmount: number; + + @Column({ name: 'currency_id', type: 'uuid', nullable: true }) + currencyId: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento, { nullable: true }) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @OneToMany(() => PresupuestoPartida, (p) => p.presupuesto) + partidas: PresupuestoPartida[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/concepto.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/concepto.service.ts new file mode 100644 index 0000000..0108de4 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/concepto.service.ts @@ -0,0 +1,160 @@ +/** + * ConceptoService - Catalogo de Conceptos de Obra + * + * Gestiona el catálogo jerárquico de conceptos de obra. + * Los conceptos pueden tener estructura padre-hijo (niveles). + * + * @module Budgets + */ + +import { Repository, IsNull } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Concepto } from '../entities/concepto.entity'; + +export interface CreateConceptoDto { + code: string; + name: string; + description?: string; + parentId?: string; + unitId?: string; + unitPrice?: number; + isComposite?: boolean; +} + +export interface UpdateConceptoDto { + name?: string; + description?: string; + unitId?: string; + unitPrice?: number; + isComposite?: boolean; +} + +export class ConceptoService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Crear un nuevo concepto con cálculo automático de nivel y path + */ + async createConcepto( + ctx: ServiceContext, + data: CreateConceptoDto + ): Promise { + let level = 0; + let path = data.code; + + if (data.parentId) { + const parent = await this.findById(ctx, data.parentId); + if (parent) { + level = parent.level + 1; + path = `${parent.path}/${data.code}`; + } + } + + return this.create(ctx, { + ...data, + level, + path, + }); + } + + /** + * Obtener conceptos raíz (sin padre) + */ + async findRootConceptos( + ctx: ServiceContext, + page = 1, + limit = 50 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { parentId: IsNull() } as any, + }); + } + + /** + * Obtener hijos de un concepto + */ + async findChildren( + ctx: ServiceContext, + parentId: string + ): Promise { + return this.find(ctx, { + where: { parentId } as any, + order: { code: 'ASC' }, + }); + } + + /** + * Obtener árbol completo de conceptos + */ + async getConceptoTree( + ctx: ServiceContext, + rootId?: string + ): Promise { + const where = rootId + ? { parentId: rootId } + : { parentId: IsNull() }; + + const roots = await this.find(ctx, { + where: where as any, + order: { code: 'ASC' }, + }); + + return this.buildTree(ctx, roots); + } + + private async buildTree( + ctx: ServiceContext, + conceptos: Concepto[] + ): Promise { + const tree: ConceptoNode[] = []; + + for (const concepto of conceptos) { + const children = await this.findChildren(ctx, concepto.id); + const childNodes = children.length > 0 + ? await this.buildTree(ctx, children) + : []; + + tree.push({ + ...concepto, + children: childNodes, + }); + } + + return tree; + } + + /** + * Buscar conceptos por código o nombre + */ + async search( + ctx: ServiceContext, + term: string, + limit = 20 + ): Promise { + return this.repository + .createQueryBuilder('c') + .where('c.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('c.deleted_at IS NULL') + .andWhere('(c.code ILIKE :term OR c.name ILIKE :term)', { + term: `%${term}%`, + }) + .orderBy('c.code', 'ASC') + .take(limit) + .getMany(); + } + + /** + * Verificar si un código ya existe + */ + async codeExists(ctx: ServiceContext, code: string): Promise { + return this.exists(ctx, { code } as any); + } +} + +interface ConceptoNode extends Concepto { + children: ConceptoNode[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/index.ts new file mode 100644 index 0000000..f7e3fe4 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/index.ts @@ -0,0 +1,6 @@ +/** + * Budgets Module - Service Exports + */ + +export * from './concepto.service'; +export * from './presupuesto.service'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/presupuesto.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/presupuesto.service.ts new file mode 100644 index 0000000..d3879be --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/budgets/services/presupuesto.service.ts @@ -0,0 +1,262 @@ +/** + * PresupuestoService - Gestión de Presupuestos de Obra + * + * Gestiona presupuestos de obra con sus partidas. + * Soporta versionamiento y aprobación. + * + * @module Budgets + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Presupuesto } from '../entities/presupuesto.entity'; +import { PresupuestoPartida } from '../entities/presupuesto-partida.entity'; + +export interface CreatePresupuestoDto { + code: string; + name: string; + description?: string; + fraccionamientoId?: string; + prototipoId?: string; + currencyId?: string; +} + +export interface AddPartidaDto { + conceptoId: string; + quantity: number; + unitPrice: number; + sequence?: number; +} + +export interface UpdatePartidaDto { + quantity?: number; + unitPrice?: number; + sequence?: number; +} + +export class PresupuestoService extends BaseService { + constructor( + repository: Repository, + private readonly partidaRepository: Repository + ) { + super(repository); + } + + /** + * Crear nuevo presupuesto + */ + async createPresupuesto( + ctx: ServiceContext, + data: CreatePresupuestoDto + ): Promise { + return this.create(ctx, { + ...data, + version: 1, + isActive: true, + totalAmount: 0, + }); + } + + /** + * Obtener presupuestos por fraccionamiento + */ + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { fraccionamientoId, isActive: true } as any, + }); + } + + /** + * Obtener presupuesto con sus partidas + */ + async findWithPartidas( + ctx: ServiceContext, + id: string + ): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: ['partidas', 'partidas.concepto'], + }); + } + + /** + * Agregar partida al presupuesto + */ + async addPartida( + ctx: ServiceContext, + presupuestoId: string, + data: AddPartidaDto + ): Promise { + const presupuesto = await this.findById(ctx, presupuestoId); + if (!presupuesto) { + throw new Error('Presupuesto not found'); + } + + const partida = this.partidaRepository.create({ + tenantId: ctx.tenantId, + presupuestoId, + conceptoId: data.conceptoId, + quantity: data.quantity, + unitPrice: data.unitPrice, + sequence: data.sequence || 0, + createdById: ctx.userId, + }); + + const savedPartida = await this.partidaRepository.save(partida); + await this.recalculateTotal(ctx, presupuestoId); + + return savedPartida; + } + + /** + * Actualizar partida + */ + async updatePartida( + ctx: ServiceContext, + partidaId: string, + data: UpdatePartidaDto + ): Promise { + const partida = await this.partidaRepository.findOne({ + where: { + id: partidaId, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + }); + + if (!partida) { + return null; + } + + const updated = this.partidaRepository.merge(partida, { + ...data, + updatedById: ctx.userId, + }); + + const saved = await this.partidaRepository.save(updated); + await this.recalculateTotal(ctx, partida.presupuestoId); + + return saved; + } + + /** + * Eliminar partida + */ + async removePartida(ctx: ServiceContext, partidaId: string): Promise { + const partida = await this.partidaRepository.findOne({ + where: { + id: partidaId, + tenantId: ctx.tenantId, + } as any, + }); + + if (!partida) { + return false; + } + + await this.partidaRepository.update( + { id: partidaId }, + { + deletedAt: new Date(), + deletedById: ctx.userId, + } + ); + + await this.recalculateTotal(ctx, partida.presupuestoId); + return true; + } + + /** + * Recalcular total del presupuesto + */ + async recalculateTotal(ctx: ServiceContext, presupuestoId: string): Promise { + const result = await this.partidaRepository + .createQueryBuilder('p') + .select('SUM(p.quantity * p.unit_price)', 'total') + .where('p.presupuesto_id = :presupuestoId', { presupuestoId }) + .andWhere('p.deleted_at IS NULL') + .getRawOne(); + + const total = parseFloat(result?.total || '0'); + + await this.repository.update( + { id: presupuestoId }, + { totalAmount: total, updatedById: ctx.userId } + ); + } + + /** + * Crear nueva versión del presupuesto + */ + async createNewVersion( + ctx: ServiceContext, + presupuestoId: string + ): Promise { + const original = await this.findWithPartidas(ctx, presupuestoId); + if (!original) { + throw new Error('Presupuesto not found'); + } + + // Desactivar versión anterior + await this.repository.update( + { id: presupuestoId }, + { isActive: false, updatedById: ctx.userId } + ); + + // Crear nueva versión + const newVersion = await this.create(ctx, { + code: original.code, + name: original.name, + description: original.description, + fraccionamientoId: original.fraccionamientoId, + prototipoId: original.prototipoId, + currencyId: original.currencyId, + version: original.version + 1, + isActive: true, + totalAmount: original.totalAmount, + }); + + // Copiar partidas + for (const partida of original.partidas) { + await this.partidaRepository.save( + this.partidaRepository.create({ + tenantId: ctx.tenantId, + presupuestoId: newVersion.id, + conceptoId: partida.conceptoId, + quantity: partida.quantity, + unitPrice: partida.unitPrice, + sequence: partida.sequence, + createdById: ctx.userId, + }) + ); + } + + return newVersion; + } + + /** + * Aprobar presupuesto + */ + async approve(ctx: ServiceContext, presupuestoId: string): Promise { + const presupuesto = await this.findById(ctx, presupuestoId); + if (!presupuesto) { + return null; + } + + return this.update(ctx, presupuestoId, { + approvedAt: new Date(), + approvedById: ctx.userId, + }); + } +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/amortizacion.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/amortizacion.entity.ts new file mode 100644 index 0000000..d413c5f --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/amortizacion.entity.ts @@ -0,0 +1,86 @@ +/** + * Amortizacion Entity + * Amortizaciones de anticipos por estimacion + * + * @module Estimates + * @table estimates.amortizaciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Anticipo } from './anticipo.entity'; +import { Estimacion } from './estimacion.entity'; + +@Entity({ schema: 'estimates', name: 'amortizaciones' }) +@Index(['anticipoId', 'estimacionId'], { unique: true }) +@Index(['tenantId']) +@Index(['anticipoId']) +@Index(['estimacionId']) +export class Amortizacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'anticipo_id', type: 'uuid' }) + anticipoId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ type: 'decimal', precision: 16, scale: 2 }) + amount: number; + + @Column({ name: 'amortization_date', type: 'date' }) + amortizationDate: Date; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Anticipo, (a) => a.amortizaciones) + @JoinColumn({ name: 'anticipo_id' }) + anticipo: Anticipo; + + @ManyToOne(() => Estimacion, (e) => e.amortizaciones) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/anticipo.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/anticipo.entity.ts new file mode 100644 index 0000000..0938f0a --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/anticipo.entity.ts @@ -0,0 +1,134 @@ +/** + * Anticipo Entity + * Anticipos otorgados a subcontratistas + * + * @module Estimates + * @table estimates.anticipos + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Amortizacion } from './amortizacion.entity'; + +export type AdvanceType = 'initial' | 'progress' | 'materials'; + +@Entity({ schema: 'estimates', name: 'anticipos' }) +@Index(['tenantId', 'advanceNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['contratoId']) +@Index(['advanceType']) +export class Anticipo { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ + name: 'advance_type', + type: 'enum', + enum: ['initial', 'progress', 'materials'], + enumName: 'estimates.advance_type', + default: 'initial', + }) + advanceType: AdvanceType; + + @Column({ name: 'advance_number', type: 'varchar', length: 30 }) + advanceNumber: string; + + @Column({ name: 'advance_date', type: 'date' }) + advanceDate: Date; + + @Column({ name: 'gross_amount', type: 'decimal', precision: 16, scale: 2 }) + grossAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'net_amount', type: 'decimal', precision: 16, scale: 2 }) + netAmount: number; + + @Column({ name: 'amortization_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + amortizationPercentage: number; + + @Column({ name: 'amortized_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + amortizedAmount: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'pending_amount', + type: 'decimal', + precision: 16, + scale: 2, + insert: false, + update: false, + }) + pendingAmount: number; + + @Column({ name: 'is_fully_amortized', type: 'boolean', default: false }) + isFullyAmortized: boolean; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => Amortizacion, (a) => a.anticipo) + amortizaciones: Amortizacion[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-concepto.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-concepto.entity.ts new file mode 100644 index 0000000..0d2aaa7 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-concepto.entity.ts @@ -0,0 +1,122 @@ +/** + * EstimacionConcepto Entity + * Lineas de concepto por estimacion + * + * @module Estimates + * @table estimates.estimacion_conceptos + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { Estimacion } from './estimacion.entity'; +import { Generador } from './generador.entity'; + +@Entity({ schema: 'estimates', name: 'estimacion_conceptos' }) +@Index(['estimacionId', 'conceptoId'], { unique: true }) +@Index(['tenantId']) +@Index(['estimacionId']) +@Index(['conceptoId']) +export class EstimacionConcepto { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ name: 'contrato_partida_id', type: 'uuid', nullable: true }) + contratoPartidaId: string | null; + + @Column({ name: 'quantity_contract', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityContract: number; + + @Column({ name: 'quantity_previous', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityPrevious: number; + + @Column({ name: 'quantity_current', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityCurrent: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'quantity_accumulated', + type: 'decimal', + precision: 12, + scale: 4, + insert: false, + update: false, + }) + quantityAccumulated: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 4, default: 0 }) + unitPrice: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'amount_current', + type: 'decimal', + precision: 14, + scale: 2, + insert: false, + update: false, + }) + amountCurrent: number; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.conceptos) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => Generador, (g) => g.estimacionConcepto) + generadores: Generador[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-workflow.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-workflow.entity.ts new file mode 100644 index 0000000..20c59f3 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion-workflow.entity.ts @@ -0,0 +1,87 @@ +/** + * EstimacionWorkflow Entity + * Historial de workflow de estimaciones + * + * @module Estimates + * @table estimates.estimacion_workflow + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Estimacion, EstimateStatus } from './estimacion.entity'; + +@Entity({ schema: 'estimates', name: 'estimacion_workflow' }) +@Index(['tenantId']) +@Index(['estimacionId']) +export class EstimacionWorkflow { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ + name: 'from_status', + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + nullable: true, + }) + fromStatus: EstimateStatus | null; + + @Column({ + name: 'to_status', + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + }) + toStatus: EstimateStatus; + + @Column({ type: 'varchar', length: 50 }) + action: string; + + @Column({ type: 'text', nullable: true }) + comments: string | null; + + @Column({ name: 'performed_by', type: 'uuid' }) + performedById: string; + + @Column({ name: 'performed_at', type: 'timestamptz', default: () => 'NOW()' }) + performedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.workflow) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'performed_by' }) + performedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion.entity.ts new file mode 100644 index 0000000..132353f --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/estimacion.entity.ts @@ -0,0 +1,173 @@ +/** + * Estimacion Entity + * Estimaciones de obra periodicas para subcontratistas + * + * @module Estimates + * @table estimates.estimaciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { EstimacionConcepto } from './estimacion-concepto.entity'; +import { Amortizacion } from './amortizacion.entity'; +import { Retencion } from './retencion.entity'; +import { EstimacionWorkflow } from './estimacion-workflow.entity'; + +export type EstimateStatus = 'draft' | 'submitted' | 'reviewed' | 'approved' | 'invoiced' | 'paid' | 'rejected' | 'cancelled'; + +@Entity({ schema: 'estimates', name: 'estimaciones' }) +@Index(['tenantId', 'estimateNumber'], { unique: true }) +@Index(['contratoId', 'sequenceNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['contratoId']) +@Index(['fraccionamientoId']) +@Index(['status']) +@Index(['periodStart', 'periodEnd']) +@Check(`"period_end" >= "period_start"`) +export class Estimacion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'estimate_number', type: 'varchar', length: 30 }) + estimateNumber: string; + + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + @Column({ name: 'sequence_number', type: 'integer' }) + sequenceNumber: number; + + @Column({ + type: 'enum', + enum: ['draft', 'submitted', 'reviewed', 'approved', 'invoiced', 'paid', 'rejected', 'cancelled'], + enumName: 'estimates.estimate_status', + default: 'draft', + }) + status: EstimateStatus; + + @Column({ type: 'decimal', precision: 16, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'advance_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + advanceAmount: number; + + @Column({ name: 'retention_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + retentionAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + totalAmount: number; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date | null; + + @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) + submittedById: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt: Date | null; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId: string | null; + + @Column({ name: 'invoiced_at', type: 'timestamptz', nullable: true }) + invoicedAt: Date | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'submitted_by' }) + submittedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'reviewed_by' }) + reviewedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => EstimacionConcepto, (c) => c.estimacion) + conceptos: EstimacionConcepto[]; + + @OneToMany(() => Amortizacion, (a) => a.estimacion) + amortizaciones: Amortizacion[]; + + @OneToMany(() => Retencion, (r) => r.estimacion) + retenciones: Retencion[]; + + @OneToMany(() => EstimacionWorkflow, (w) => w.estimacion) + workflow: EstimacionWorkflow[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/fondo-garantia.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/fondo-garantia.entity.ts new file mode 100644 index 0000000..31d2739 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/fondo-garantia.entity.ts @@ -0,0 +1,92 @@ +/** + * FondoGarantia Entity + * Fondo de garantia acumulado por contrato + * + * @module Estimates + * @table estimates.fondo_garantia + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; + +@Entity({ schema: 'estimates', name: 'fondo_garantia' }) +@Index(['contratoId'], { unique: true }) +@Index(['tenantId']) +export class FondoGarantia { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'contrato_id', type: 'uuid' }) + contratoId: string; + + @Column({ name: 'accumulated_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + accumulatedAmount: number; + + @Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, default: 0 }) + releasedAmount: number; + + // Columna calculada (GENERATED ALWAYS AS) - solo lectura + @Column({ + name: 'pending_amount', + type: 'decimal', + precision: 16, + scale: 2, + insert: false, + update: false, + }) + pendingAmount: number; + + @Column({ name: 'release_date', type: 'date', nullable: true }) + releaseDate: Date | null; + + @Column({ name: 'released_at', type: 'timestamptz', nullable: true }) + releasedAt: Date | null; + + @Column({ name: 'released_by', type: 'uuid', nullable: true }) + releasedById: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User) + @JoinColumn({ name: 'released_by' }) + releasedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/generador.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/generador.entity.ts new file mode 100644 index 0000000..5ca305f --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/generador.entity.ts @@ -0,0 +1,125 @@ +/** + * Generador Entity + * Numeros generadores (soporte de cantidades para estimaciones) + * + * @module Estimates + * @table estimates.generadores + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { EstimacionConcepto } from './estimacion-concepto.entity'; + +export type GeneratorStatus = 'draft' | 'in_progress' | 'completed' | 'approved'; + +@Entity({ schema: 'estimates', name: 'generadores' }) +@Index(['tenantId']) +@Index(['estimacionConceptoId']) +@Index(['status']) +export class Generador { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_concepto_id', type: 'uuid' }) + estimacionConceptoId: string; + + @Column({ name: 'generator_number', type: 'varchar', length: 30 }) + generatorNumber: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: ['draft', 'in_progress', 'completed', 'approved'], + enumName: 'estimates.generator_status', + default: 'draft', + }) + status: GeneratorStatus; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string | null; + + @Column({ name: 'departamento_id', type: 'uuid', nullable: true }) + departamentoId: string | null; + + @Column({ name: 'location_description', type: 'varchar', length: 255, nullable: true }) + locationDescription: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantity: number; + + @Column({ type: 'text', nullable: true }) + formula: string | null; + + @Column({ name: 'photo_url', type: 'varchar', length: 500, nullable: true }) + photoUrl: string | null; + + @Column({ name: 'sketch_url', type: 'varchar', length: 500, nullable: true }) + sketchUrl: string | null; + + @Column({ name: 'captured_by', type: 'uuid' }) + capturedById: string; + + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => EstimacionConcepto, (ec) => ec.generadores) + @JoinColumn({ name: 'estimacion_concepto_id' }) + estimacionConcepto: EstimacionConcepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'captured_by' }) + capturedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/index.ts new file mode 100644 index 0000000..76096aa --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/index.ts @@ -0,0 +1,13 @@ +/** + * Estimates Module - Entity Exports + * MAI-008: Estimaciones y Facturacion + */ + +export * from './estimacion.entity'; +export * from './estimacion-concepto.entity'; +export * from './generador.entity'; +export * from './anticipo.entity'; +export * from './amortizacion.entity'; +export * from './retencion.entity'; +export * from './fondo-garantia.entity'; +export * from './estimacion-workflow.entity'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/retencion.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/retencion.entity.ts new file mode 100644 index 0000000..1bfc70c --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/entities/retencion.entity.ts @@ -0,0 +1,99 @@ +/** + * Retencion Entity + * Retenciones aplicadas a estimaciones + * + * @module Estimates + * @table estimates.retenciones + * @ddl schemas/04-estimates-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Estimacion } from './estimacion.entity'; + +export type RetentionType = 'guarantee' | 'tax' | 'penalty' | 'other'; + +@Entity({ schema: 'estimates', name: 'retenciones' }) +@Index(['tenantId']) +@Index(['estimacionId']) +@Index(['retentionType']) +export class Retencion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'estimacion_id', type: 'uuid' }) + estimacionId: string; + + @Column({ + name: 'retention_type', + type: 'enum', + enum: ['guarantee', 'tax', 'penalty', 'other'], + enumName: 'estimates.retention_type', + }) + retentionType: RetentionType; + + @Column({ type: 'varchar', length: 255 }) + description: string; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + percentage: number | null; + + @Column({ type: 'decimal', precision: 16, scale: 2 }) + amount: number; + + @Column({ name: 'release_date', type: 'date', nullable: true }) + releaseDate: Date | null; + + @Column({ name: 'released_at', type: 'timestamptz', nullable: true }) + releasedAt: Date | null; + + @Column({ name: 'released_amount', type: 'decimal', precision: 16, scale: 2, nullable: true }) + releasedAmount: number | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Estimacion, (e) => e.retenciones) + @JoinColumn({ name: 'estimacion_id' }) + estimacion: Estimacion; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/estimacion.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/estimacion.service.ts new file mode 100644 index 0000000..4ab2c6d --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/estimacion.service.ts @@ -0,0 +1,424 @@ +/** + * EstimacionService - Gestión de Estimaciones de Obra + * + * Gestiona estimaciones periódicas con workflow de aprobación. + * Incluye cálculo de anticipos, retenciones e IVA. + * + * @module Estimates + */ + +import { Repository, DataSource } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { Estimacion, EstimateStatus } from '../entities/estimacion.entity'; +import { EstimacionConcepto } from '../entities/estimacion-concepto.entity'; +import { Generador } from '../entities/generador.entity'; +import { EstimacionWorkflow } from '../entities/estimacion-workflow.entity'; + +export interface CreateEstimacionDto { + contratoId: string; + fraccionamientoId: string; + periodStart: Date; + periodEnd: Date; + notes?: string; +} + +export interface AddConceptoDto { + conceptoId: string; + contratoPartidaId?: string; + quantityContract?: number; + quantityPrevious?: number; + quantityCurrent: number; + unitPrice: number; + notes?: string; +} + +export interface AddGeneradorDto { + generatorNumber: string; + description?: string; + loteId?: string; + departamentoId?: string; + locationDescription?: string; + quantity: number; + formula?: string; + photoUrl?: string; + sketchUrl?: string; +} + +export interface EstimacionFilters { + contratoId?: string; + fraccionamientoId?: string; + status?: EstimateStatus; + periodFrom?: Date; + periodTo?: Date; +} + +export class EstimacionService extends BaseService { + constructor( + repository: Repository, + private readonly conceptoRepository: Repository, + private readonly generadorRepository: Repository, + private readonly workflowRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Crear nueva estimación + */ + async createEstimacion( + ctx: ServiceContext, + data: CreateEstimacionDto + ): Promise { + const sequenceNumber = await this.getNextSequenceNumber(ctx, data.contratoId); + const estimateNumber = await this.generateEstimateNumber(ctx, data.contratoId, sequenceNumber); + + const estimacion = await this.create(ctx, { + ...data, + estimateNumber, + sequenceNumber, + status: 'draft', + }); + + // Registrar en workflow + await this.addWorkflowEntry(ctx, estimacion.id, null, 'draft', 'create', 'Estimación creada'); + + return estimacion; + } + + /** + * Obtener siguiente número de secuencia + */ + private async getNextSequenceNumber( + ctx: ServiceContext, + contratoId: string + ): Promise { + const result = await this.repository + .createQueryBuilder('e') + .select('MAX(e.sequence_number)', 'maxNumber') + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .getRawOne(); + + return (result?.maxNumber || 0) + 1; + } + + /** + * Generar número de estimación + */ + private async generateEstimateNumber( + ctx: ServiceContext, + contratoId: string, + sequenceNumber: number + ): Promise { + const year = new Date().getFullYear(); + return `EST-${year}-${contratoId.substring(0, 8).toUpperCase()}-${sequenceNumber.toString().padStart(3, '0')}`; + } + + /** + * Obtener estimaciones por contrato + */ + async findByContrato( + ctx: ServiceContext, + contratoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { contratoId } as any, + }); + } + + /** + * Obtener estimaciones con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: EstimacionFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('e') + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.deleted_at IS NULL'); + + if (filters.contratoId) { + qb.andWhere('e.contrato_id = :contratoId', { contratoId: filters.contratoId }); + } + if (filters.fraccionamientoId) { + qb.andWhere('e.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId }); + } + if (filters.status) { + qb.andWhere('e.status = :status', { status: filters.status }); + } + if (filters.periodFrom) { + qb.andWhere('e.period_start >= :periodFrom', { periodFrom: filters.periodFrom }); + } + if (filters.periodTo) { + qb.andWhere('e.period_end <= :periodTo', { periodTo: filters.periodTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('e.sequence_number', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener estimación con detalles completos + */ + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: [ + 'conceptos', + 'conceptos.concepto', + 'conceptos.generadores', + 'amortizaciones', + 'retenciones', + 'workflow', + ], + }); + } + + /** + * Agregar concepto a estimación + */ + async addConcepto( + ctx: ServiceContext, + estimacionId: string, + data: AddConceptoDto + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion || estimacion.status !== 'draft') { + throw new Error('Cannot modify non-draft estimation'); + } + + const concepto = this.conceptoRepository.create({ + tenantId: ctx.tenantId, + estimacionId, + conceptoId: data.conceptoId, + contratoPartidaId: data.contratoPartidaId, + quantityContract: data.quantityContract || 0, + quantityPrevious: data.quantityPrevious || 0, + quantityCurrent: data.quantityCurrent, + unitPrice: data.unitPrice, + notes: data.notes, + createdById: ctx.userId, + }); + + const savedConcepto = await this.conceptoRepository.save(concepto); + await this.recalculateTotals(ctx, estimacionId); + + return savedConcepto; + } + + /** + * Agregar generador a concepto de estimación + */ + async addGenerador( + ctx: ServiceContext, + estimacionConceptoId: string, + data: AddGeneradorDto + ): Promise { + const concepto = await this.conceptoRepository.findOne({ + where: { id: estimacionConceptoId, tenantId: ctx.tenantId } as any, + relations: ['estimacion'], + }); + + if (!concepto) { + throw new Error('Concepto not found'); + } + + const generador = this.generadorRepository.create({ + tenantId: ctx.tenantId, + estimacionConceptoId, + generatorNumber: data.generatorNumber, + description: data.description, + loteId: data.loteId, + departamentoId: data.departamentoId, + locationDescription: data.locationDescription, + quantity: data.quantity, + formula: data.formula, + photoUrl: data.photoUrl, + sketchUrl: data.sketchUrl, + status: 'draft', + capturedById: ctx.userId, + createdById: ctx.userId, + }); + + return this.generadorRepository.save(generador); + } + + /** + * Recalcular totales de estimación + */ + async recalculateTotals(ctx: ServiceContext, estimacionId: string): Promise { + // Ejecutar función de PostgreSQL + await this.dataSource.query('SELECT estimates.calculate_estimate_totals($1)', [estimacionId]); + } + + /** + * Cambiar estado de estimación + */ + async changeStatus( + ctx: ServiceContext, + estimacionId: string, + newStatus: EstimateStatus, + action: string, + comments?: string + ): Promise { + const estimacion = await this.findById(ctx, estimacionId); + if (!estimacion) { + return null; + } + + const validTransitions: Record = { + draft: ['submitted'], + submitted: ['reviewed', 'rejected'], + reviewed: ['approved', 'rejected'], + approved: ['invoiced'], + invoiced: ['paid'], + paid: [], + rejected: ['draft'], + cancelled: [], + }; + + if (!validTransitions[estimacion.status]?.includes(newStatus)) { + throw new Error(`Invalid status transition from ${estimacion.status} to ${newStatus}`); + } + + const updateData: Partial = { status: newStatus }; + + switch (newStatus) { + case 'submitted': + updateData.submittedAt = new Date(); + updateData.submittedById = ctx.userId; + break; + case 'reviewed': + updateData.reviewedAt = new Date(); + updateData.reviewedById = ctx.userId; + break; + case 'approved': + updateData.approvedAt = new Date(); + updateData.approvedById = ctx.userId; + break; + case 'invoiced': + updateData.invoicedAt = new Date(); + break; + case 'paid': + updateData.paidAt = new Date(); + break; + } + + const updated = await this.update(ctx, estimacionId, updateData); + + // Registrar en workflow + await this.addWorkflowEntry(ctx, estimacionId, estimacion.status, newStatus, action, comments); + + return updated; + } + + /** + * Agregar entrada al workflow + */ + private async addWorkflowEntry( + ctx: ServiceContext, + estimacionId: string, + fromStatus: EstimateStatus | null, + toStatus: EstimateStatus, + action: string, + comments?: string + ): Promise { + await this.workflowRepository.save( + this.workflowRepository.create({ + tenantId: ctx.tenantId, + estimacionId, + fromStatus, + toStatus, + action, + comments, + performedById: ctx.userId, + createdById: ctx.userId, + }) + ); + } + + /** + * Enviar estimación para revisión + */ + async submit(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión'); + } + + /** + * Revisar estimación + */ + async review(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada'); + } + + /** + * Aprobar estimación + */ + async approve(ctx: ServiceContext, estimacionId: string): Promise { + return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada'); + } + + /** + * Rechazar estimación + */ + async reject( + ctx: ServiceContext, + estimacionId: string, + reason: string + ): Promise { + return this.changeStatus(ctx, estimacionId, 'rejected', 'reject', reason); + } + + /** + * Obtener resumen de estimaciones por contrato + */ + async getContractSummary(ctx: ServiceContext, contratoId: string): Promise { + const result = await this.repository + .createQueryBuilder('e') + .select([ + 'COUNT(*) as total_estimates', + 'SUM(CASE WHEN e.status = \'approved\' THEN e.total_amount ELSE 0 END) as total_approved', + 'SUM(CASE WHEN e.status = \'paid\' THEN e.total_amount ELSE 0 END) as total_paid', + ]) + .where('e.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('e.contrato_id = :contratoId', { contratoId }) + .andWhere('e.deleted_at IS NULL') + .getRawOne(); + + return { + totalEstimates: parseInt(result?.total_estimates || '0'), + totalApproved: parseFloat(result?.total_approved || '0'), + totalPaid: parseFloat(result?.total_paid || '0'), + }; + } +} + +interface ContractEstimateSummary { + totalEstimates: number; + totalApproved: number; + totalPaid: number; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/index.ts new file mode 100644 index 0000000..2845720 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/estimates/services/index.ts @@ -0,0 +1,6 @@ +/** + * Estimates Module - Service Exports + * MAI-008: Estimaciones y Facturación + */ + +export * from './estimacion.service'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/avance-obra.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/avance-obra.entity.ts new file mode 100644 index 0000000..3e112a9 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/avance-obra.entity.ts @@ -0,0 +1,127 @@ +/** + * AvanceObra Entity + * Registro de avances fisicos de obra por lote/departamento + * + * @module Progress + * @table construction.avances_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { FotoAvance } from './foto-avance.entity'; + +export type AdvanceStatus = 'pending' | 'captured' | 'reviewed' | 'approved' | 'rejected'; + +@Entity({ schema: 'construction', name: 'avances_obra' }) +@Index(['tenantId']) +@Index(['loteId']) +@Index(['conceptoId']) +@Index(['captureDate']) +@Check(`"lote_id" IS NOT NULL AND "departamento_id" IS NULL OR "lote_id" IS NULL AND "departamento_id" IS NOT NULL`) +export class AvanceObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'lote_id', type: 'uuid', nullable: true }) + loteId: string | null; + + @Column({ name: 'departamento_id', type: 'uuid', nullable: true }) + departamentoId: string | null; + + @Column({ name: 'concepto_id', type: 'uuid' }) + conceptoId: string; + + @Column({ name: 'capture_date', type: 'date' }) + captureDate: Date; + + @Column({ name: 'quantity_executed', type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityExecuted: number; + + @Column({ name: 'percentage_executed', type: 'decimal', precision: 5, scale: 2, default: 0 }) + percentageExecuted: number; + + @Column({ + type: 'enum', + enum: ['pending', 'captured', 'reviewed', 'approved', 'rejected'], + enumName: 'construction.advance_status', + default: 'pending', + }) + status: AdvanceStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ name: 'captured_by', type: 'uuid' }) + capturedById: string; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedById: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt: Date | null; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedById: string | null; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Concepto) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto; + + @ManyToOne(() => User) + @JoinColumn({ name: 'captured_by' }) + capturedBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'reviewed_by' }) + reviewedBy: User | null; + + @ManyToOne(() => User) + @JoinColumn({ name: 'approved_by' }) + approvedBy: User | null; + + @OneToMany(() => FotoAvance, (f) => f.avance) + fotos: FotoAvance[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/bitacora-obra.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/bitacora-obra.entity.ts new file mode 100644 index 0000000..5dae0f8 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/bitacora-obra.entity.ts @@ -0,0 +1,102 @@ +/** + * BitacoraObra Entity + * Registro diario de bitacora de obra + * + * @module Progress + * @table construction.bitacora_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; + +@Entity({ schema: 'construction', name: 'bitacora_obra' }) +@Index(['fraccionamientoId', 'entryNumber'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class BitacoraObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ name: 'entry_date', type: 'date' }) + entryDate: Date; + + @Column({ name: 'entry_number', type: 'integer' }) + entryNumber: number; + + @Column({ type: 'varchar', length: 50, nullable: true }) + weather: string | null; + + @Column({ name: 'temperature_max', type: 'decimal', precision: 4, scale: 1, nullable: true }) + temperatureMax: number | null; + + @Column({ name: 'temperature_min', type: 'decimal', precision: 4, scale: 1, nullable: true }) + temperatureMin: number | null; + + @Column({ name: 'workers_count', type: 'integer', default: 0 }) + workersCount: number; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'text', nullable: true }) + observations: string | null; + + @Column({ type: 'text', nullable: true }) + incidents: string | null; + + @Column({ name: 'registered_by', type: 'uuid' }) + registeredById: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'registered_by' }) + registeredBy: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/foto-avance.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/foto-avance.entity.ts new file mode 100644 index 0000000..652a0d3 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/foto-avance.entity.ts @@ -0,0 +1,87 @@ +/** + * FotoAvance Entity + * Evidencia fotografica de avances de obra + * + * @module Progress + * @table construction.fotos_avance + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { AvanceObra } from './avance-obra.entity'; + +@Entity({ schema: 'construction', name: 'fotos_avance' }) +@Index(['tenantId']) +@Index(['avanceId']) +export class FotoAvance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'avance_id', type: 'uuid' }) + avanceId: string; + + @Column({ name: 'file_url', type: 'varchar', length: 500 }) + fileUrl: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255, nullable: true }) + fileName: string | null; + + @Column({ name: 'file_size', type: 'integer', nullable: true }) + fileSize: number | null; + + @Column({ name: 'mime_type', type: 'varchar', length: 50, nullable: true }) + mimeType: string | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + // PostGIS Point para ubicacion GPS + @Column({ + type: 'geometry', + spatialFeatureType: 'Point', + srid: 4326, + nullable: true, + }) + location: string | null; + + @Column({ name: 'captured_at', type: 'timestamptz', default: () => 'NOW()' }) + capturedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => AvanceObra, (a) => a.fotos) + @JoinColumn({ name: 'avance_id' }) + avance: AvanceObra; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/index.ts new file mode 100644 index 0000000..d7cc1a8 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/index.ts @@ -0,0 +1,10 @@ +/** + * Progress Module - Entity Exports + * MAI-005: Control de Obra + */ + +export * from './avance-obra.entity'; +export * from './foto-avance.entity'; +export * from './bitacora-obra.entity'; +export * from './programa-obra.entity'; +export * from './programa-actividad.entity'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-actividad.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-actividad.entity.ts new file mode 100644 index 0000000..7973d18 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-actividad.entity.ts @@ -0,0 +1,107 @@ +/** + * ProgramaActividad Entity + * Actividades del programa de obra (WBS) + * + * @module Progress + * @table construction.programa_actividades + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Concepto } from '../../budgets/entities/concepto.entity'; +import { ProgramaObra } from './programa-obra.entity'; + +@Entity({ schema: 'construction', name: 'programa_actividades' }) +@Index(['tenantId']) +@Index(['programaId']) +export class ProgramaActividad { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'programa_id', type: 'uuid' }) + programaId: string; + + @Column({ name: 'concepto_id', type: 'uuid', nullable: true }) + conceptoId: string | null; + + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string | null; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'integer', default: 0 }) + sequence: number; + + @Column({ name: 'planned_start', type: 'date', nullable: true }) + plannedStart: Date | null; + + @Column({ name: 'planned_end', type: 'date', nullable: true }) + plannedEnd: Date | null; + + @Column({ name: 'planned_quantity', type: 'decimal', precision: 12, scale: 4, default: 0 }) + plannedQuantity: number; + + @Column({ name: 'planned_weight', type: 'decimal', precision: 8, scale: 4, default: 0 }) + plannedWeight: number; + + @Column({ name: 'wbs_code', type: 'varchar', length: 50, nullable: true }) + wbsCode: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => ProgramaObra, (p) => p.actividades) + @JoinColumn({ name: 'programa_id' }) + programa: ProgramaObra; + + @ManyToOne(() => Concepto, { nullable: true }) + @JoinColumn({ name: 'concepto_id' }) + concepto: Concepto | null; + + @ManyToOne(() => ProgramaActividad, (a) => a.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: ProgramaActividad | null; + + @OneToMany(() => ProgramaActividad, (a) => a.parent) + children: ProgramaActividad[]; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-obra.entity.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-obra.entity.ts new file mode 100644 index 0000000..171d0d7 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/entities/programa-obra.entity.ts @@ -0,0 +1,91 @@ +/** + * ProgramaObra Entity + * Programa maestro de obra (planificacion) + * + * @module Progress + * @table construction.programa_obra + * @ddl schemas/01-construction-schema-ddl.sql + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { Tenant } from '../../core/entities/tenant.entity'; +import { User } from '../../core/entities/user.entity'; +import { Fraccionamiento } from '../../construction/entities/fraccionamiento.entity'; +import { ProgramaActividad } from './programa-actividad.entity'; + +@Entity({ schema: 'construction', name: 'programa_obra' }) +@Index(['tenantId', 'code', 'version'], { unique: true }) +@Index(['tenantId']) +@Index(['fraccionamientoId']) +export class ProgramaObra { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'fraccionamiento_id', type: 'uuid' }) + fraccionamientoId: string; + + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'integer', default: 1 }) + version: number; + + @Column({ name: 'start_date', type: 'date' }) + startDate: Date; + + @Column({ name: 'end_date', type: 'date' }) + endDate: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdById: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz', nullable: true }) + updatedAt: Date | null; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedById: string | null; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date | null; + + @Column({ name: 'deleted_by', type: 'uuid', nullable: true }) + deletedById: string | null; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Fraccionamiento) + @JoinColumn({ name: 'fraccionamiento_id' }) + fraccionamiento: Fraccionamiento; + + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + createdBy: User | null; + + @OneToMany(() => ProgramaActividad, (a) => a.programa) + actividades: ProgramaActividad[]; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/avance-obra.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/avance-obra.service.ts new file mode 100644 index 0000000..a982260 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/avance-obra.service.ts @@ -0,0 +1,284 @@ +/** + * AvanceObraService - Gestión de Avances de Obra + * + * Gestiona el registro y aprobación de avances físicos de obra. + * Incluye workflow de captura -> revisión -> aprobación. + * + * @module Progress + */ + +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { AvanceObra, AdvanceStatus } from '../entities/avance-obra.entity'; +import { FotoAvance } from '../entities/foto-avance.entity'; + +export interface CreateAvanceDto { + loteId?: string; + departamentoId?: string; + conceptoId: string; + captureDate: Date; + quantityExecuted: number; + percentageExecuted?: number; + notes?: string; +} + +export interface AddFotoDto { + fileUrl: string; + fileName?: string; + fileSize?: number; + mimeType?: string; + description?: string; + location?: { lat: number; lng: number }; +} + +export interface AvanceFilters { + loteId?: string; + departamentoId?: string; + conceptoId?: string; + status?: AdvanceStatus; + dateFrom?: Date; + dateTo?: Date; +} + +export class AvanceObraService extends BaseService { + constructor( + repository: Repository, + private readonly fotoRepository: Repository + ) { + super(repository); + } + + /** + * Crear nuevo avance (captura) + */ + async createAvance( + ctx: ServiceContext, + data: CreateAvanceDto + ): Promise { + if (!data.loteId && !data.departamentoId) { + throw new Error('Either loteId or departamentoId is required'); + } + + if (data.loteId && data.departamentoId) { + throw new Error('Cannot specify both loteId and departamentoId'); + } + + return this.create(ctx, { + ...data, + status: 'captured', + capturedById: ctx.userId, + }); + } + + /** + * Obtener avances por lote + */ + async findByLote( + ctx: ServiceContext, + loteId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { loteId } as any, + }); + } + + /** + * Obtener avances por departamento + */ + async findByDepartamento( + ctx: ServiceContext, + departamentoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { departamentoId } as any, + }); + } + + /** + * Obtener avances con filtros + */ + async findWithFilters( + ctx: ServiceContext, + filters: AvanceFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('a') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL'); + + if (filters.loteId) { + qb.andWhere('a.lote_id = :loteId', { loteId: filters.loteId }); + } + if (filters.departamentoId) { + qb.andWhere('a.departamento_id = :departamentoId', { departamentoId: filters.departamentoId }); + } + if (filters.conceptoId) { + qb.andWhere('a.concepto_id = :conceptoId', { conceptoId: filters.conceptoId }); + } + if (filters.status) { + qb.andWhere('a.status = :status', { status: filters.status }); + } + if (filters.dateFrom) { + qb.andWhere('a.capture_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('a.capture_date <= :dateTo', { dateTo: filters.dateTo }); + } + + const skip = (page - 1) * limit; + qb.orderBy('a.capture_date', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener avance con fotos + */ + async findWithFotos(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as any, + relations: ['fotos', 'concepto', 'capturedBy'], + }); + } + + /** + * Agregar foto al avance + */ + async addFoto( + ctx: ServiceContext, + avanceId: string, + data: AddFotoDto + ): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance) { + throw new Error('Avance not found'); + } + + const location = data.location + ? `POINT(${data.location.lng} ${data.location.lat})` + : null; + + const foto = this.fotoRepository.create({ + tenantId: ctx.tenantId, + avanceId, + fileUrl: data.fileUrl, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + description: data.description, + location, + createdById: ctx.userId, + }); + + return this.fotoRepository.save(foto); + } + + /** + * Revisar avance + */ + async review(ctx: ServiceContext, avanceId: string): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || avance.status !== 'captured') { + return null; + } + + return this.update(ctx, avanceId, { + status: 'reviewed', + reviewedById: ctx.userId, + reviewedAt: new Date(), + }); + } + + /** + * Aprobar avance + */ + async approve(ctx: ServiceContext, avanceId: string): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || avance.status !== 'reviewed') { + return null; + } + + return this.update(ctx, avanceId, { + status: 'approved', + approvedById: ctx.userId, + approvedAt: new Date(), + }); + } + + /** + * Rechazar avance + */ + async reject( + ctx: ServiceContext, + avanceId: string, + reason: string + ): Promise { + const avance = await this.findById(ctx, avanceId); + if (!avance || !['captured', 'reviewed'].includes(avance.status)) { + return null; + } + + return this.update(ctx, avanceId, { + status: 'rejected', + notes: reason, + }); + } + + /** + * Calcular avance acumulado por concepto + */ + async getAccumulatedProgress( + ctx: ServiceContext, + loteId?: string, + departamentoId?: string + ): Promise { + const qb = this.repository + .createQueryBuilder('a') + .select('a.concepto_id', 'conceptoId') + .addSelect('SUM(a.quantity_executed)', 'totalQuantity') + .addSelect('AVG(a.percentage_executed)', 'avgPercentage') + .where('a.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('a.deleted_at IS NULL') + .andWhere('a.status = :status', { status: 'approved' }) + .groupBy('a.concepto_id'); + + if (loteId) { + qb.andWhere('a.lote_id = :loteId', { loteId }); + } + if (departamentoId) { + qb.andWhere('a.departamento_id = :departamentoId', { departamentoId }); + } + + return qb.getRawMany(); + } +} + +interface ConceptProgress { + conceptoId: string; + totalQuantity: number; + avgPercentage: number; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/bitacora-obra.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/bitacora-obra.service.ts new file mode 100644 index 0000000..676de68 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/bitacora-obra.service.ts @@ -0,0 +1,209 @@ +/** + * BitacoraObraService - Bitácora de Obra + * + * Gestiona el registro diario de bitácora de obra. + * Genera automáticamente el número de entrada secuencial. + * + * @module Progress + */ + +import { Repository } from 'typeorm'; +import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; +import { BitacoraObra } from '../entities/bitacora-obra.entity'; + +export interface CreateBitacoraDto { + fraccionamientoId: string; + entryDate: Date; + weather?: string; + temperatureMax?: number; + temperatureMin?: number; + workersCount?: number; + description: string; + observations?: string; + incidents?: string; +} + +export interface UpdateBitacoraDto { + weather?: string; + temperatureMax?: number; + temperatureMin?: number; + workersCount?: number; + description?: string; + observations?: string; + incidents?: string; +} + +export interface BitacoraFilters { + dateFrom?: Date; + dateTo?: Date; + hasIncidents?: boolean; +} + +export class BitacoraObraService extends BaseService { + constructor(repository: Repository) { + super(repository); + } + + /** + * Crear nueva entrada de bitácora + */ + async createEntry( + ctx: ServiceContext, + data: CreateBitacoraDto + ): Promise { + const entryNumber = await this.getNextEntryNumber(ctx, data.fraccionamientoId); + + return this.create(ctx, { + ...data, + entryNumber, + registeredById: ctx.userId, + }); + } + + /** + * Obtener siguiente número de entrada + */ + private async getNextEntryNumber( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const result = await this.repository + .createQueryBuilder('b') + .select('MAX(b.entry_number)', 'maxNumber') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .getRawOne(); + + return (result?.maxNumber || 0) + 1; + } + + /** + * Obtener bitácora por fraccionamiento + */ + async findByFraccionamiento( + ctx: ServiceContext, + fraccionamientoId: string, + page = 1, + limit = 20 + ): Promise> { + return this.findAll(ctx, { + page, + limit, + where: { fraccionamientoId } as any, + }); + } + + /** + * Obtener bitácora con filtros + */ + async findWithFilters( + ctx: ServiceContext, + fraccionamientoId: string, + filters: BitacoraFilters, + page = 1, + limit = 20 + ): Promise> { + const qb = this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL'); + + if (filters.dateFrom) { + qb.andWhere('b.entry_date >= :dateFrom', { dateFrom: filters.dateFrom }); + } + if (filters.dateTo) { + qb.andWhere('b.entry_date <= :dateTo', { dateTo: filters.dateTo }); + } + if (filters.hasIncidents !== undefined) { + if (filters.hasIncidents) { + qb.andWhere('b.incidents IS NOT NULL'); + } else { + qb.andWhere('b.incidents IS NULL'); + } + } + + const skip = (page - 1) * limit; + qb.orderBy('b.entry_date', 'DESC').skip(skip).take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Obtener entrada por fecha + */ + async findByDate( + ctx: ServiceContext, + fraccionamientoId: string, + date: Date + ): Promise { + return this.findOne(ctx, { + fraccionamientoId, + entryDate: date, + } as any); + } + + /** + * Obtener última entrada + */ + async findLatest( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const entries = await this.find(ctx, { + where: { fraccionamientoId } as any, + order: { entryNumber: 'DESC' }, + take: 1, + }); + + return entries[0] || null; + } + + /** + * Obtener estadísticas de bitácora + */ + async getStats( + ctx: ServiceContext, + fraccionamientoId: string + ): Promise { + const totalEntries = await this.count(ctx, { fraccionamientoId } as any); + + const incidentsCount = await this.repository + .createQueryBuilder('b') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL') + .andWhere('b.incidents IS NOT NULL') + .getCount(); + + const avgWorkers = await this.repository + .createQueryBuilder('b') + .select('AVG(b.workers_count)', 'avg') + .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) + .andWhere('b.deleted_at IS NULL') + .getRawOne(); + + return { + totalEntries, + entriesWithIncidents: incidentsCount, + avgWorkersCount: parseFloat(avgWorkers?.avg || '0'), + }; + } +} + +interface BitacoraStats { + totalEntries: number; + entriesWithIncidents: number; + avgWorkersCount: number; +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/index.ts new file mode 100644 index 0000000..89c3b16 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/modules/progress/services/index.ts @@ -0,0 +1,7 @@ +/** + * Progress Module - Service Exports + * MAI-005: Control de Obra + */ + +export * from './avance-obra.service'; +export * from './bitacora-obra.service'; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/api.constants.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/api.constants.ts new file mode 100644 index 0000000..b8d04af --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/api.constants.ts @@ -0,0 +1,249 @@ +/** + * API Constants - SSOT (Single Source of Truth) + * + * Todas las rutas de API, versiones y endpoints. + * NO hardcodear rutas en controllers o frontend. + * + * @module @shared/constants/api + */ + +/** + * API Version + */ +export const API_VERSION = 'v1'; +export const API_PREFIX = `/api/${API_VERSION}`; + +/** + * API Routes organized by Module + */ +export const API_ROUTES = { + // Base + ROOT: '/', + HEALTH: '/health', + DOCS: `${API_PREFIX}/docs`, + + // Auth Module + AUTH: { + BASE: `${API_PREFIX}/auth`, + LOGIN: `${API_PREFIX}/auth/login`, + LOGOUT: `${API_PREFIX}/auth/logout`, + REFRESH: `${API_PREFIX}/auth/refresh`, + REGISTER: `${API_PREFIX}/auth/register`, + FORGOT_PASSWORD: `${API_PREFIX}/auth/forgot-password`, + RESET_PASSWORD: `${API_PREFIX}/auth/reset-password`, + ME: `${API_PREFIX}/auth/me`, + CHANGE_PASSWORD: `${API_PREFIX}/auth/change-password`, + }, + + // Users Module + USERS: { + BASE: `${API_PREFIX}/users`, + BY_ID: (id: string) => `${API_PREFIX}/users/${id}`, + ROLES: (id: string) => `${API_PREFIX}/users/${id}/roles`, + }, + + // Tenants Module + TENANTS: { + BASE: `${API_PREFIX}/tenants`, + BY_ID: (id: string) => `${API_PREFIX}/tenants/${id}`, + CURRENT: `${API_PREFIX}/tenants/current`, + }, + + // Construction Module + PROYECTOS: { + BASE: `${API_PREFIX}/proyectos`, + BY_ID: (id: string) => `${API_PREFIX}/proyectos/${id}`, + FRACCIONAMIENTOS: (id: string) => `${API_PREFIX}/proyectos/${id}/fraccionamientos`, + DASHBOARD: (id: string) => `${API_PREFIX}/proyectos/${id}/dashboard`, + PROGRESS: (id: string) => `${API_PREFIX}/proyectos/${id}/progress`, + }, + + FRACCIONAMIENTOS: { + BASE: `${API_PREFIX}/fraccionamientos`, + BY_ID: (id: string) => `${API_PREFIX}/fraccionamientos/${id}`, + ETAPAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/etapas`, + MANZANAS: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/manzanas`, + LOTES: (id: string) => `${API_PREFIX}/fraccionamientos/${id}/lotes`, + }, + + PRESUPUESTOS: { + BASE: `${API_PREFIX}/presupuestos`, + BY_ID: (id: string) => `${API_PREFIX}/presupuestos/${id}`, + PARTIDAS: (id: string) => `${API_PREFIX}/presupuestos/${id}/partidas`, + COMPARE: (id: string) => `${API_PREFIX}/presupuestos/${id}/compare`, + VERSIONS: (id: string) => `${API_PREFIX}/presupuestos/${id}/versions`, + }, + + AVANCES: { + BASE: `${API_PREFIX}/avances`, + BY_ID: (id: string) => `${API_PREFIX}/avances/${id}`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/avances`, + CURVA_S: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/curva-s`, + FOTOS: (id: string) => `${API_PREFIX}/avances/${id}/fotos`, + }, + + // HR Module + EMPLOYEES: { + BASE: `${API_PREFIX}/employees`, + BY_ID: (id: string) => `${API_PREFIX}/employees/${id}`, + ASISTENCIAS: (id: string) => `${API_PREFIX}/employees/${id}/asistencias`, + CAPACITACIONES: (id: string) => `${API_PREFIX}/employees/${id}/capacitaciones`, + }, + + ASISTENCIAS: { + BASE: `${API_PREFIX}/asistencias`, + BY_ID: (id: string) => `${API_PREFIX}/asistencias/${id}`, + CHECK_IN: `${API_PREFIX}/asistencias/check-in`, + CHECK_OUT: `${API_PREFIX}/asistencias/check-out`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/asistencias`, + }, + + CUADRILLAS: { + BASE: `${API_PREFIX}/cuadrillas`, + BY_ID: (id: string) => `${API_PREFIX}/cuadrillas/${id}`, + MIEMBROS: (id: string) => `${API_PREFIX}/cuadrillas/${id}/miembros`, + }, + + // HSE Module + INCIDENTES: { + BASE: `${API_PREFIX}/incidentes`, + BY_ID: (id: string) => `${API_PREFIX}/incidentes/${id}`, + INVOLUCRADOS: (id: string) => `${API_PREFIX}/incidentes/${id}/involucrados`, + ACCIONES: (id: string) => `${API_PREFIX}/incidentes/${id}/acciones`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/incidentes`, + }, + + CAPACITACIONES: { + BASE: `${API_PREFIX}/capacitaciones`, + BY_ID: (id: string) => `${API_PREFIX}/capacitaciones/${id}`, + PARTICIPANTES: (id: string) => `${API_PREFIX}/capacitaciones/${id}/participantes`, + CERTIFICADOS: (id: string) => `${API_PREFIX}/capacitaciones/${id}/certificados`, + }, + + INSPECCIONES: { + BASE: `${API_PREFIX}/inspecciones`, + BY_ID: (id: string) => `${API_PREFIX}/inspecciones/${id}`, + HALLAZGOS: (id: string) => `${API_PREFIX}/inspecciones/${id}/hallazgos`, + }, + + EPP: { + BASE: `${API_PREFIX}/epp`, + BY_ID: (id: string) => `${API_PREFIX}/epp/${id}`, + ASIGNACIONES: `${API_PREFIX}/epp/asignaciones`, + ENTREGAS: `${API_PREFIX}/epp/entregas`, + STOCK: `${API_PREFIX}/epp/stock`, + }, + + // Estimates Module + ESTIMACIONES: { + BASE: `${API_PREFIX}/estimaciones`, + BY_ID: (id: string) => `${API_PREFIX}/estimaciones/${id}`, + CONCEPTOS: (id: string) => `${API_PREFIX}/estimaciones/${id}/conceptos`, + GENERADORES: (id: string) => `${API_PREFIX}/estimaciones/${id}/generadores`, + WORKFLOW: (id: string) => `${API_PREFIX}/estimaciones/${id}/workflow`, + SUBMIT: (id: string) => `${API_PREFIX}/estimaciones/${id}/submit`, + APPROVE: (id: string) => `${API_PREFIX}/estimaciones/${id}/approve`, + REJECT: (id: string) => `${API_PREFIX}/estimaciones/${id}/reject`, + }, + + // INFONAVIT Module + INFONAVIT: { + BASE: `${API_PREFIX}/infonavit`, + REGISTRO: `${API_PREFIX}/infonavit/registro`, + OFERTA: `${API_PREFIX}/infonavit/oferta`, + DERECHOHABIENTES: `${API_PREFIX}/infonavit/derechohabientes`, + ASIGNACIONES: `${API_PREFIX}/infonavit/asignaciones`, + ACTAS: `${API_PREFIX}/infonavit/actas`, + REPORTES: `${API_PREFIX}/infonavit/reportes`, + }, + + // Inventory Module + ALMACENES: { + BASE: `${API_PREFIX}/almacenes`, + BY_ID: (id: string) => `${API_PREFIX}/almacenes/${id}`, + BY_PROYECTO: (proyectoId: string) => `${API_PREFIX}/proyectos/${proyectoId}/almacenes`, + STOCK: (id: string) => `${API_PREFIX}/almacenes/${id}/stock`, + }, + + REQUISICIONES: { + BASE: `${API_PREFIX}/requisiciones`, + BY_ID: (id: string) => `${API_PREFIX}/requisiciones/${id}`, + LINEAS: (id: string) => `${API_PREFIX}/requisiciones/${id}/lineas`, + SUBMIT: (id: string) => `${API_PREFIX}/requisiciones/${id}/submit`, + APPROVE: (id: string) => `${API_PREFIX}/requisiciones/${id}/approve`, + }, + + // Purchase Module + COMPRAS: { + BASE: `${API_PREFIX}/compras`, + BY_ID: (id: string) => `${API_PREFIX}/compras/${id}`, + LINEAS: (id: string) => `${API_PREFIX}/compras/${id}/lineas`, + COMPARATIVO: `${API_PREFIX}/compras/comparativo`, + RECEPCIONES: (id: string) => `${API_PREFIX}/compras/${id}/recepciones`, + }, + + PROVEEDORES: { + BASE: `${API_PREFIX}/proveedores`, + BY_ID: (id: string) => `${API_PREFIX}/proveedores/${id}`, + COTIZACIONES: (id: string) => `${API_PREFIX}/proveedores/${id}/cotizaciones`, + }, + + // Contracts Module + CONTRATOS: { + BASE: `${API_PREFIX}/contratos`, + BY_ID: (id: string) => `${API_PREFIX}/contratos/${id}`, + PARTIDAS: (id: string) => `${API_PREFIX}/contratos/${id}/partidas`, + ESTIMACIONES: (id: string) => `${API_PREFIX}/contratos/${id}/estimaciones`, + }, + + // Reports Module + REPORTS: { + BASE: `${API_PREFIX}/reports`, + DASHBOARD: `${API_PREFIX}/reports/dashboard`, + AVANCE_FISICO: `${API_PREFIX}/reports/avance-fisico`, + AVANCE_FINANCIERO: `${API_PREFIX}/reports/avance-financiero`, + CURVA_S: `${API_PREFIX}/reports/curva-s`, + PRESUPUESTO_VS_REAL: `${API_PREFIX}/reports/presupuesto-vs-real`, + KPI_HSE: `${API_PREFIX}/reports/kpi-hse`, + EXPORT: `${API_PREFIX}/reports/export`, + }, +} as const; + +/** + * HTTP Methods + */ +export const HTTP_METHODS = { + GET: 'GET', + POST: 'POST', + PUT: 'PUT', + PATCH: 'PATCH', + DELETE: 'DELETE', +} as const; + +/** + * HTTP Status Codes + */ +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + UNPROCESSABLE_ENTITY: 422, + INTERNAL_SERVER_ERROR: 500, + SERVICE_UNAVAILABLE: 503, +} as const; + +/** + * Content Types + */ +export const CONTENT_TYPES = { + JSON: 'application/json', + FORM_URLENCODED: 'application/x-www-form-urlencoded', + MULTIPART: 'multipart/form-data', + PDF: 'application/pdf', + EXCEL: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +} as const; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/database.constants.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/database.constants.ts new file mode 100644 index 0000000..14c4d78 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/database.constants.ts @@ -0,0 +1,315 @@ +/** + * Database Constants - SSOT (Single Source of Truth) + * + * IMPORTANTE: Este archivo es la UNICA fuente de verdad para nombres de + * schemas, tablas y columnas. Cualquier hardcoding sera detectado por + * el script validate-constants-usage.ts + * + * @module @shared/constants/database + */ + +/** + * Database Schemas + * Todos los schemas de la base de datos PostgreSQL + */ +export const DB_SCHEMAS = { + // Auth & Core + AUTH: 'auth', + CORE: 'core', + + // Domain Schemas + CONSTRUCTION: 'construction', + HR: 'hr', + HSE: 'hse', + ESTIMATES: 'estimates', + INFONAVIT: 'infonavit', + INVENTORY: 'inventory', + PURCHASE: 'purchase', + + // System Schemas + FINANCIAL: 'financial', + ANALYTICS: 'analytics', + AUDIT: 'audit', + SYSTEM: 'system', +} as const; + +export type DBSchema = typeof DB_SCHEMAS[keyof typeof DB_SCHEMAS]; + +/** + * Database Tables organized by Schema + */ +export const DB_TABLES = { + // Auth Schema + [DB_SCHEMAS.AUTH]: { + USERS: 'users', + ROLES: 'roles', + PERMISSIONS: 'permissions', + ROLE_PERMISSIONS: 'role_permissions', + USER_ROLES: 'user_roles', + SESSIONS: 'sessions', + REFRESH_TOKENS: 'refresh_tokens', + TENANTS: 'tenants', + TENANT_USERS: 'tenant_users', + PASSWORD_RESETS: 'password_resets', + }, + + // Core Schema + [DB_SCHEMAS.CORE]: { + COMPANIES: 'companies', + PARTNERS: 'partners', + CURRENCIES: 'currencies', + COUNTRIES: 'countries', + STATES: 'states', + CITIES: 'cities', + UOM: 'units_of_measure', + UOM_CATEGORIES: 'uom_categories', + SEQUENCES: 'sequences', + ATTACHMENTS: 'attachments', + }, + + // Construction Schema (24 tables) + [DB_SCHEMAS.CONSTRUCTION]: { + // Project Structure (8) + PROYECTOS: 'proyectos', + FRACCIONAMIENTOS: 'fraccionamientos', + ETAPAS: 'etapas', + MANZANAS: 'manzanas', + LOTES: 'lotes', + TORRES: 'torres', + NIVELES: 'niveles', + DEPARTAMENTOS: 'departamentos', + PROTOTIPOS: 'prototipos', + + // Budget & Concepts (3) + CONCEPTOS: 'conceptos', + PRESUPUESTOS: 'presupuestos', + PRESUPUESTO_PARTIDAS: 'presupuesto_partidas', + + // Schedule & Progress (5) + PROGRAMA_OBRA: 'programa_obra', + PROGRAMA_ACTIVIDADES: 'programa_actividades', + AVANCES_OBRA: 'avances_obra', + FOTOS_AVANCE: 'fotos_avance', + BITACORA_OBRA: 'bitacora_obra', + + // Quality (5) + CHECKLISTS: 'checklists', + CHECKLIST_ITEMS: 'checklist_items', + INSPECCIONES: 'inspecciones', + INSPECCION_RESULTADOS: 'inspeccion_resultados', + TICKETS_POSTVENTA: 'tickets_postventa', + + // Contracts (3) + SUBCONTRATISTAS: 'subcontratistas', + CONTRATOS: 'contratos', + CONTRATO_PARTIDAS: 'contrato_partidas', + }, + + // HR Schema (8 tables) + [DB_SCHEMAS.HR]: { + EMPLOYEES: 'employees', + EMPLOYEE_CONSTRUCTION: 'employee_construction', + PUESTOS: 'puestos', + ASISTENCIAS: 'asistencias', + ASISTENCIA_BIOMETRICO: 'asistencia_biometrico', + GEOCERCAS: 'geocercas', + DESTAJO: 'destajo', + DESTAJO_DETALLE: 'destajo_detalle', + CUADRILLAS: 'cuadrillas', + CUADRILLA_MIEMBROS: 'cuadrilla_miembros', + EMPLOYEE_FRACCIONAMIENTOS: 'employee_fraccionamientos', + }, + + // HSE Schema (58 tables - main groups) + [DB_SCHEMAS.HSE]: { + // Incidents (5) + INCIDENTES: 'incidentes', + INCIDENTE_INVOLUCRADOS: 'incidente_involucrados', + INCIDENTE_ACCIONES: 'incidente_acciones', + INCIDENTE_EVIDENCIAS: 'incidente_evidencias', + INCIDENTE_CAUSAS: 'incidente_causas', + + // Training (6) + CAPACITACIONES: 'capacitaciones', + CAPACITACION_PARTICIPANTES: 'capacitacion_participantes', + CAPACITACION_MATERIALES: 'capacitacion_materiales', + CERTIFICACIONES: 'certificaciones', + CERTIFICACION_EMPLEADOS: 'certificacion_empleados', + PLAN_CAPACITACION: 'plan_capacitacion', + + // Inspections (7) + INSPECCIONES_SEGURIDAD: 'inspecciones_seguridad', + INSPECCION_HALLAZGOS: 'inspeccion_hallazgos', + CHECKLIST_SEGURIDAD: 'checklist_seguridad', + CHECKLIST_SEGURIDAD_ITEMS: 'checklist_seguridad_items', + AREAS_RIESGO: 'areas_riesgo', + RONDAS_SEGURIDAD: 'rondas_seguridad', + RONDA_PUNTOS: 'ronda_puntos', + + // EPP (7) + EPP_CATALOGO: 'epp_catalogo', + EPP_ASIGNACIONES: 'epp_asignaciones', + EPP_ENTREGAS: 'epp_entregas', + EPP_DEVOLUCIONES: 'epp_devoluciones', + EPP_INSPECCIONES: 'epp_inspecciones', + EPP_VIDA_UTIL: 'epp_vida_util', + EPP_STOCK: 'epp_stock', + + // STPS Compliance (11) + NORMAS_STPS: 'normas_stps', + REQUISITOS_NORMA: 'requisitos_norma', + CUMPLIMIENTO_NORMA: 'cumplimiento_norma', + AUDITORIAS_STPS: 'auditorias_stps', + AUDITORIA_HALLAZGOS: 'auditoria_hallazgos', + PLANES_ACCION: 'planes_accion', + ACCIONES_CORRECTIVAS: 'acciones_correctivas', + COMISION_SEGURIDAD: 'comision_seguridad', + COMISION_MIEMBROS: 'comision_miembros', + RECORRIDOS_COMISION: 'recorridos_comision', + ACTAS_COMISION: 'actas_comision', + + // Environmental (9) + IMPACTOS_AMBIENTALES: 'impactos_ambientales', + RESIDUOS: 'residuos', + RESIDUO_MOVIMIENTOS: 'residuo_movimientos', + MANIFIESTOS_RESIDUOS: 'manifiestos_residuos', + MONITOREO_AMBIENTAL: 'monitoreo_ambiental', + PERMISOS_AMBIENTALES: 'permisos_ambientales', + PROGRAMAS_AMBIENTALES: 'programas_ambientales', + INDICADORES_AMBIENTALES: 'indicadores_ambientales', + EVENTOS_AMBIENTALES: 'eventos_ambientales', + + // Work Permits (8) + PERMISOS_TRABAJO: 'permisos_trabajo', + PERMISO_RIESGOS: 'permiso_riesgos', + PERMISO_AUTORIZACIONES: 'permiso_autorizaciones', + PERMISOS_ALTURA: 'permisos_altura', + PERMISOS_CALIENTE: 'permisos_caliente', + PERMISOS_CONFINADO: 'permisos_confinado', + PERMISOS_ELECTRICO: 'permisos_electrico', + PERMISOS_EXCAVACION: 'permisos_excavacion', + + // KPIs (7) + KPI_CONFIGURACION: 'kpi_configuracion', + KPI_VALORES: 'kpi_valores', + KPI_METAS: 'kpi_metas', + DASHBOARDS_HSE: 'dashboards_hse', + ALERTAS_HSE: 'alertas_hse', + REPORTES_HSE: 'reportes_hse', + ESTADISTICAS_PERIODO: 'estadisticas_periodo', + }, + + // Estimates Schema (8 tables) + [DB_SCHEMAS.ESTIMATES]: { + ESTIMACIONES: 'estimaciones', + ESTIMACION_CONCEPTOS: 'estimacion_conceptos', + GENERADORES: 'generadores', + ANTICIPOS: 'anticipos', + AMORTIZACIONES: 'amortizaciones', + RETENCIONES: 'retenciones', + FONDO_GARANTIA: 'fondo_garantia', + ESTIMACION_WORKFLOW: 'estimacion_workflow', + }, + + // INFONAVIT Schema (8 tables) + [DB_SCHEMAS.INFONAVIT]: { + REGISTRO_INFONAVIT: 'registro_infonavit', + OFERTA_VIVIENDA: 'oferta_vivienda', + DERECHOHABIENTES: 'derechohabientes', + ASIGNACION_VIVIENDA: 'asignacion_vivienda', + ACTAS: 'actas', + ACTA_VIVIENDAS: 'acta_viviendas', + REPORTES_INFONAVIT: 'reportes_infonavit', + HISTORICO_PUNTOS: 'historico_puntos', + }, + + // Inventory Extension Schema (4 tables) + [DB_SCHEMAS.INVENTORY]: { + ALMACENES_PROYECTO: 'almacenes_proyecto', + REQUISICIONES_OBRA: 'requisiciones_obra', + REQUISICION_LINEAS: 'requisicion_lineas', + CONSUMOS_OBRA: 'consumos_obra', + // Base tables (reference) + PRODUCTS: 'products', + LOCATIONS: 'locations', + STOCK_MOVES: 'stock_moves', + STOCK_QUANTS: 'stock_quants', + }, + + // Purchase Extension Schema (5 tables) + [DB_SCHEMAS.PURCHASE]: { + PURCHASE_ORDER_CONSTRUCTION: 'purchase_order_construction', + SUPPLIER_CONSTRUCTION: 'supplier_construction', + COMPARATIVO_COTIZACIONES: 'comparativo_cotizaciones', + COMPARATIVO_PROVEEDORES: 'comparativo_proveedores', + COMPARATIVO_PRODUCTOS: 'comparativo_productos', + // Base tables (reference) + PURCHASE_ORDERS: 'purchase_orders', + PURCHASE_ORDER_LINES: 'purchase_order_lines', + SUPPLIERS: 'suppliers', + }, + + // Audit Schema + [DB_SCHEMAS.AUDIT]: { + AUDIT_LOG: 'audit_log', + CHANGE_HISTORY: 'change_history', + USER_ACTIVITY: 'user_activity', + }, +} as const; + +/** + * Common Column Names (to avoid hardcoding) + */ +export const DB_COLUMNS = { + // Audit columns + ID: 'id', + CREATED_AT: 'created_at', + UPDATED_AT: 'updated_at', + CREATED_BY: 'created_by', + UPDATED_BY: 'updated_by', + DELETED_AT: 'deleted_at', + + // Multi-tenant columns + TENANT_ID: 'tenant_id', + + // Common FK columns + USER_ID: 'user_id', + PROJECT_ID: 'proyecto_id', + FRACCIONAMIENTO_ID: 'fraccionamiento_id', + EMPLOYEE_ID: 'employee_id', + + // Status columns + STATUS: 'status', + IS_ACTIVE: 'is_active', + + // Analytic columns (Odoo pattern) + ANALYTIC_ACCOUNT_ID: 'analytic_account_id', +} as const; + +/** + * Helper function to get full table name + */ +export function getFullTableName(schema: DBSchema, table: string): string { + return `${schema}.${table}`; +} + +/** + * Get schema.table reference + */ +export const TABLE_REFS = { + // Auth + USERS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].USERS), + TENANTS: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].TENANTS), + ROLES: getFullTableName(DB_SCHEMAS.AUTH, DB_TABLES[DB_SCHEMAS.AUTH].ROLES), + + // Construction + PROYECTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].PROYECTOS), + FRACCIONAMIENTOS: getFullTableName(DB_SCHEMAS.CONSTRUCTION, DB_TABLES[DB_SCHEMAS.CONSTRUCTION].FRACCIONAMIENTOS), + + // HR + EMPLOYEES: getFullTableName(DB_SCHEMAS.HR, DB_TABLES[DB_SCHEMAS.HR].EMPLOYEES), + + // HSE + INCIDENTES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].INCIDENTES), + CAPACITACIONES: getFullTableName(DB_SCHEMAS.HSE, DB_TABLES[DB_SCHEMAS.HSE].CAPACITACIONES), +} as const; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/enums.constants.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/enums.constants.ts new file mode 100644 index 0000000..03c4330 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/enums.constants.ts @@ -0,0 +1,494 @@ +/** + * Enums Constants - SSOT (Single Source of Truth) + * + * Todos los enums del sistema. Estos se sincronizan automaticamente + * al frontend usando el script sync-enums.ts + * + * @module @shared/constants/enums + */ + +// ============================================================================ +// AUTH & USERS +// ============================================================================ + +/** + * Roles del sistema de construccion + */ +export const ROLES = { + // Super Admin (Plataforma) + SUPER_ADMIN: 'super_admin', + + // Tenant Admin + ADMIN: 'admin', + + // Direccion + DIRECTOR_GENERAL: 'director_general', + DIRECTOR_PROYECTOS: 'director_proyectos', + DIRECTOR_CONSTRUCCION: 'director_construccion', + + // Gerencias + GERENTE_ADMINISTRATIVO: 'gerente_administrativo', + GERENTE_OPERACIONES: 'gerente_operaciones', + + // Ingenieria y Control + INGENIERO_RESIDENTE: 'ingeniero_residente', + INGENIERO_COSTOS: 'ingeniero_costos', + CONTROL_OBRA: 'control_obra', + PLANEADOR: 'planeador', + + // Supervisores + SUPERVISOR_OBRA: 'supervisor_obra', + SUPERVISOR_HSE: 'supervisor_hse', + SUPERVISOR_CALIDAD: 'supervisor_calidad', + + // Compras y Almacen + COMPRAS: 'compras', + ALMACENISTA: 'almacenista', + + // RRHH + RRHH: 'rrhh', + NOMINA: 'nomina', + + // Finanzas + CONTADOR: 'contador', + TESORERO: 'tesorero', + + // Postventa + POSTVENTA: 'postventa', + + // Externos + SUBCONTRATISTA: 'subcontratista', + PROVEEDOR: 'proveedor', + DERECHOHABIENTE: 'derechohabiente', + + // Solo lectura + VIEWER: 'viewer', +} as const; + +export type Role = typeof ROLES[keyof typeof ROLES]; + +/** + * Estados de cuenta de usuario + */ +export const USER_STATUS = { + ACTIVE: 'active', + INACTIVE: 'inactive', + PENDING: 'pending', + SUSPENDED: 'suspended', + BLOCKED: 'blocked', +} as const; + +export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]; + +// ============================================================================ +// PROYECTOS Y ESTRUCTURA +// ============================================================================ + +/** + * Estados de proyecto + */ +export const PROJECT_STATUS = { + DRAFT: 'draft', + PLANNING: 'planning', + BIDDING: 'bidding', + AWARDED: 'awarded', + ACTIVE: 'active', + PAUSED: 'paused', + COMPLETED: 'completed', + CANCELLED: 'cancelled', +} as const; + +export type ProjectStatus = typeof PROJECT_STATUS[keyof typeof PROJECT_STATUS]; + +/** + * Tipos de proyecto + */ +export const PROJECT_TYPE = { + HORIZONTAL: 'horizontal', // Casas individuales + VERTICAL: 'vertical', // Edificios/Torres + MIXED: 'mixed', // Combinado + INFRASTRUCTURE: 'infrastructure', // Infraestructura +} as const; + +export type ProjectType = typeof PROJECT_TYPE[keyof typeof PROJECT_TYPE]; + +/** + * Estados de fraccionamiento + */ +export const FRACCIONAMIENTO_STATUS = { + ACTIVE: 'activo', + PAUSED: 'pausado', + COMPLETED: 'completado', + CANCELLED: 'cancelado', +} as const; + +export type FraccionamientoStatus = typeof FRACCIONAMIENTO_STATUS[keyof typeof FRACCIONAMIENTO_STATUS]; + +/** + * Estados de lote + */ +export const LOT_STATUS = { + AVAILABLE: 'disponible', + RESERVED: 'apartado', + SOLD: 'vendido', + IN_CONSTRUCTION: 'en_construccion', + DELIVERED: 'entregado', + WARRANTY: 'en_garantia', +} as const; + +export type LotStatus = typeof LOT_STATUS[keyof typeof LOT_STATUS]; + +// ============================================================================ +// PRESUPUESTOS Y COSTOS +// ============================================================================ + +/** + * Tipos de concepto de obra + */ +export const CONCEPT_TYPE = { + MATERIAL: 'material', + LABOR: 'mano_obra', + EQUIPMENT: 'equipo', + SUBCONTRACT: 'subcontrato', + INDIRECT: 'indirecto', + OVERHEAD: 'overhead', + UTILITY: 'utilidad', +} as const; + +export type ConceptType = typeof CONCEPT_TYPE[keyof typeof CONCEPT_TYPE]; + +/** + * Estados de presupuesto + */ +export const BUDGET_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + CONTRACTED: 'contratado', + CLOSED: 'cerrado', +} as const; + +export type BudgetStatus = typeof BUDGET_STATUS[keyof typeof BUDGET_STATUS]; + +// ============================================================================ +// COMPRAS E INVENTARIOS +// ============================================================================ + +/** + * Estados de orden de compra + */ +export const PURCHASE_ORDER_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + CONFIRMED: 'confirmado', + PARTIAL: 'parcial', + RECEIVED: 'recibido', + CANCELLED: 'cancelado', +} as const; + +export type PurchaseOrderStatus = typeof PURCHASE_ORDER_STATUS[keyof typeof PURCHASE_ORDER_STATUS]; + +/** + * Estados de requisicion + */ +export const REQUISITION_STATUS = { + DRAFT: 'borrador', + SUBMITTED: 'enviado', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + ORDERED: 'ordenado', + CLOSED: 'cerrado', +} as const; + +export type RequisitionStatus = typeof REQUISITION_STATUS[keyof typeof REQUISITION_STATUS]; + +/** + * Tipos de movimiento de inventario + */ +export const STOCK_MOVE_TYPE = { + INCOMING: 'entrada', + OUTGOING: 'salida', + TRANSFER: 'traspaso', + ADJUSTMENT: 'ajuste', + RETURN: 'devolucion', + CONSUMPTION: 'consumo', +} as const; + +export type StockMoveType = typeof STOCK_MOVE_TYPE[keyof typeof STOCK_MOVE_TYPE]; + +// ============================================================================ +// ESTIMACIONES +// ============================================================================ + +/** + * Estados de estimacion + */ +export const ESTIMATION_STATUS = { + DRAFT: 'borrador', + IN_REVIEW: 'en_revision', + SUBMITTED: 'enviado', + CLIENT_REVIEW: 'revision_cliente', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + PAID: 'pagado', + CANCELLED: 'cancelado', +} as const; + +export type EstimationStatus = typeof ESTIMATION_STATUS[keyof typeof ESTIMATION_STATUS]; + +/** + * Tipos de retencion + */ +export const RETENTION_TYPE = { + WARRANTY: 'garantia', + ADVANCE_AMORTIZATION: 'amortizacion_anticipo', + IMSS: 'imss', + ISR: 'isr', + OTHER: 'otro', +} as const; + +export type RetentionType = typeof RETENTION_TYPE[keyof typeof RETENTION_TYPE]; + +// ============================================================================ +// HSE (Health, Safety & Environment) +// ============================================================================ + +/** + * Severidad de incidente + */ +export const INCIDENT_SEVERITY = { + LOW: 'bajo', + MEDIUM: 'medio', + HIGH: 'alto', + CRITICAL: 'critico', + FATAL: 'fatal', +} as const; + +export type IncidentSeverity = typeof INCIDENT_SEVERITY[keyof typeof INCIDENT_SEVERITY]; + +/** + * Tipos de incidente + */ +export const INCIDENT_TYPE = { + ACCIDENT: 'accidente', + NEAR_MISS: 'casi_accidente', + UNSAFE_CONDITION: 'condicion_insegura', + UNSAFE_ACT: 'acto_inseguro', + FIRST_AID: 'primeros_auxilios', + ENVIRONMENTAL: 'ambiental', +} as const; + +export type IncidentType = typeof INCIDENT_TYPE[keyof typeof INCIDENT_TYPE]; + +/** + * Estados de incidente + */ +export const INCIDENT_STATUS = { + REPORTED: 'reportado', + UNDER_INVESTIGATION: 'en_investigacion', + PENDING_ACTIONS: 'pendiente_acciones', + ACTIONS_IN_PROGRESS: 'acciones_en_progreso', + CLOSED: 'cerrado', +} as const; + +export type IncidentStatus = typeof INCIDENT_STATUS[keyof typeof INCIDENT_STATUS]; + +/** + * Tipos de capacitacion + */ +export const TRAINING_TYPE = { + INDUCTION: 'induccion', + SAFETY: 'seguridad', + TECHNICAL: 'tecnico', + REGULATORY: 'normativo', + REFRESHER: 'actualizacion', + CERTIFICATION: 'certificacion', +} as const; + +export type TrainingType = typeof TRAINING_TYPE[keyof typeof TRAINING_TYPE]; + +/** + * Tipos de permiso de trabajo + */ +export const WORK_PERMIT_TYPE = { + HOT_WORK: 'trabajo_caliente', + CONFINED_SPACE: 'espacio_confinado', + HEIGHT_WORK: 'trabajo_altura', + ELECTRICAL: 'electrico', + EXCAVATION: 'excavacion', + LIFTING: 'izaje', +} as const; + +export type WorkPermitType = typeof WORK_PERMIT_TYPE[keyof typeof WORK_PERMIT_TYPE]; + +// ============================================================================ +// RRHH +// ============================================================================ + +/** + * Tipos de empleado + */ +export const EMPLOYEE_TYPE = { + PERMANENT: 'planta', + TEMPORARY: 'temporal', + CONTRACTOR: 'contratista', + INTERN: 'practicante', +} as const; + +export type EmployeeType = typeof EMPLOYEE_TYPE[keyof typeof EMPLOYEE_TYPE]; + +/** + * Tipos de asistencia + */ +export const ATTENDANCE_TYPE = { + CHECK_IN: 'entrada', + CHECK_OUT: 'salida', + BREAK_START: 'inicio_descanso', + BREAK_END: 'fin_descanso', +} as const; + +export type AttendanceType = typeof ATTENDANCE_TYPE[keyof typeof ATTENDANCE_TYPE]; + +/** + * Metodos de validacion de asistencia + */ +export const ATTENDANCE_VALIDATION = { + GPS: 'gps', + BIOMETRIC: 'biometrico', + QR: 'qr', + MANUAL: 'manual', + NFC: 'nfc', +} as const; + +export type AttendanceValidation = typeof ATTENDANCE_VALIDATION[keyof typeof ATTENDANCE_VALIDATION]; + +// ============================================================================ +// INFONAVIT +// ============================================================================ + +/** + * Estados de asignacion INFONAVIT + */ +export const INFONAVIT_ASSIGNMENT_STATUS = { + AVAILABLE: 'disponible', + IN_PROCESS: 'en_proceso', + ASSIGNED: 'asignado', + DOCUMENTED: 'documentado', + REGISTERED: 'registrado', + DELIVERED: 'entregado', +} as const; + +export type InfonavitAssignmentStatus = typeof INFONAVIT_ASSIGNMENT_STATUS[keyof typeof INFONAVIT_ASSIGNMENT_STATUS]; + +/** + * Programas INFONAVIT + */ +export const INFONAVIT_PROGRAM = { + TRADICIONAL: 'tradicional', + COFINAVIT: 'cofinavit', + APOYO_INFONAVIT: 'apoyo_infonavit', + UNAMOS_CREDITOS: 'unamos_creditos', + MEJORAVIT: 'mejoravit', +} as const; + +export type InfonavitProgram = typeof INFONAVIT_PROGRAM[keyof typeof INFONAVIT_PROGRAM]; + +// ============================================================================ +// CALIDAD Y POSTVENTA +// ============================================================================ + +/** + * Estados de ticket postventa + */ +export const TICKET_STATUS = { + OPEN: 'abierto', + IN_PROGRESS: 'en_proceso', + PENDING_CUSTOMER: 'pendiente_cliente', + PENDING_PARTS: 'pendiente_refacciones', + RESOLVED: 'resuelto', + CLOSED: 'cerrado', +} as const; + +export type TicketStatus = typeof TICKET_STATUS[keyof typeof TICKET_STATUS]; + +/** + * Prioridad de ticket + */ +export const TICKET_PRIORITY = { + LOW: 'baja', + MEDIUM: 'media', + HIGH: 'alta', + URGENT: 'urgente', +} as const; + +export type TicketPriority = typeof TICKET_PRIORITY[keyof typeof TICKET_PRIORITY]; + +// ============================================================================ +// DOCUMENTOS +// ============================================================================ + +/** + * Estados de documento + */ +export const DOCUMENT_STATUS = { + DRAFT: 'borrador', + PENDING_REVIEW: 'pendiente_revision', + APPROVED: 'aprobado', + REJECTED: 'rechazado', + OBSOLETE: 'obsoleto', +} as const; + +export type DocumentStatus = typeof DOCUMENT_STATUS[keyof typeof DOCUMENT_STATUS]; + +/** + * Tipos de documento + */ +export const DOCUMENT_TYPE = { + PLAN: 'plano', + CONTRACT: 'contrato', + PERMIT: 'permiso', + CERTIFICATE: 'certificado', + REPORT: 'reporte', + PHOTO: 'fotografia', + OTHER: 'otro', +} as const; + +export type DocumentType = typeof DOCUMENT_TYPE[keyof typeof DOCUMENT_TYPE]; + +// ============================================================================ +// WORKFLOW +// ============================================================================ + +/** + * Acciones de workflow + */ +export const WORKFLOW_ACTION = { + SUBMIT: 'submit', + APPROVE: 'approve', + REJECT: 'reject', + RETURN: 'return', + CANCEL: 'cancel', + REOPEN: 'reopen', +} as const; + +export type WorkflowAction = typeof WORKFLOW_ACTION[keyof typeof WORKFLOW_ACTION]; + +// ============================================================================ +// AUDIT +// ============================================================================ + +/** + * Tipos de accion de auditoria + */ +export const AUDIT_ACTION = { + CREATE: 'create', + UPDATE: 'update', + DELETE: 'delete', + VIEW: 'view', + EXPORT: 'export', + LOGIN: 'login', + LOGOUT: 'logout', +} as const; + +export type AuditAction = typeof AUDIT_ACTION[keyof typeof AUDIT_ACTION]; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/index.ts index a2c2ace..ea46ef4 100644 --- a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/index.ts +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/constants/index.ts @@ -1,66 +1,194 @@ /** - * Constants - * Constantes globales del proyecto + * Constants - SSOT Entry Point + * + * Este archivo es el punto de entrada para todas las constantes del sistema. + * Exporta desde los modulos especializados para mantener SSOT. + * + * @module @shared/constants */ -// Contexto de aplicacion para PostgreSQL RLS +// ============================================================================ +// DATABASE CONSTANTS +// ============================================================================ +export { + DB_SCHEMAS, + DB_TABLES, + DB_COLUMNS, + TABLE_REFS, + getFullTableName, + type DBSchema, +} from './database.constants'; + +// ============================================================================ +// API CONSTANTS +// ============================================================================ +export { + API_VERSION, + API_PREFIX, + API_ROUTES, + HTTP_METHODS, + HTTP_STATUS, + CONTENT_TYPES, +} from './api.constants'; + +// ============================================================================ +// ENUMS +// ============================================================================ +export { + // Auth + ROLES, + USER_STATUS, + type Role, + type UserStatus, + + // Projects + PROJECT_STATUS, + PROJECT_TYPE, + FRACCIONAMIENTO_STATUS, + LOT_STATUS, + type ProjectStatus, + type ProjectType, + type FraccionamientoStatus, + type LotStatus, + + // Budget + CONCEPT_TYPE, + BUDGET_STATUS, + type ConceptType, + type BudgetStatus, + + // Purchases + PURCHASE_ORDER_STATUS, + REQUISITION_STATUS, + STOCK_MOVE_TYPE, + type PurchaseOrderStatus, + type RequisitionStatus, + type StockMoveType, + + // Estimates + ESTIMATION_STATUS, + RETENTION_TYPE, + type EstimationStatus, + type RetentionType, + + // HSE + INCIDENT_SEVERITY, + INCIDENT_TYPE, + INCIDENT_STATUS, + TRAINING_TYPE, + WORK_PERMIT_TYPE, + type IncidentSeverity, + type IncidentType, + type IncidentStatus, + type TrainingType, + type WorkPermitType, + + // HR + EMPLOYEE_TYPE, + ATTENDANCE_TYPE, + ATTENDANCE_VALIDATION, + type EmployeeType, + type AttendanceType, + type AttendanceValidation, + + // INFONAVIT + INFONAVIT_ASSIGNMENT_STATUS, + INFONAVIT_PROGRAM, + type InfonavitAssignmentStatus, + type InfonavitProgram, + + // Quality + TICKET_STATUS, + TICKET_PRIORITY, + type TicketStatus, + type TicketPriority, + + // Documents + DOCUMENT_STATUS, + DOCUMENT_TYPE, + type DocumentStatus, + type DocumentType, + + // Workflow + WORKFLOW_ACTION, + type WorkflowAction, + + // Audit + AUDIT_ACTION, + type AuditAction, +} from './enums.constants'; + +// ============================================================================ +// APP CONFIG CONSTANTS +// ============================================================================ + +/** + * Application Context for PostgreSQL RLS + */ export const APP_CONTEXT = { TENANT_ID: 'app.current_tenant', USER_ID: 'app.current_user_id', } as const; -// Estados de proyecto -export const PROJECT_STATUS = { - PLANNING: 'planning', - ACTIVE: 'active', - PAUSED: 'paused', - COMPLETED: 'completed', - CANCELLED: 'cancelled', -} as const; - -// Tipos de proyecto -export const PROJECT_TYPE = { - HORIZONTAL: 'horizontal', - VERTICAL: 'vertical', - MIXED: 'mixed', -} as const; - -// Estados de fraccionamiento -export const FRACCIONAMIENTO_STATUS = { - ACTIVE: 'activo', - PAUSED: 'pausado', - COMPLETED: 'completado', - CANCELLED: 'cancelado', -} as const; - -// Roles del sistema -export const ROLES = { - SUPER_ADMIN: 'super_admin', - ADMIN: 'admin', - MANAGER: 'manager', - SUPERVISOR: 'supervisor', - OPERATOR: 'operator', - VIEWER: 'viewer', -} as const; - -// Headers HTTP personalizados +/** + * Custom HTTP Headers + */ export const CUSTOM_HEADERS = { TENANT_ID: 'x-tenant-id', CORRELATION_ID: 'x-correlation-id', + API_KEY: 'x-api-key', } as const; -// Pagination defaults +/** + * Pagination Defaults + */ export const PAGINATION = { DEFAULT_PAGE: 1, DEFAULT_LIMIT: 20, MAX_LIMIT: 100, } as const; -// Regex patterns +/** + * Regex Patterns for Validation + */ export const PATTERNS = { UUID: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, RFC: /^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/, CURP: /^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9A-Z][0-9]$/, NSS: /^[0-9]{11}$/, EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + PHONE_MX: /^(\+52)?[0-9]{10}$/, + POSTAL_CODE_MX: /^[0-9]{5}$/, +} as const; + +/** + * File Upload Limits + */ +export const FILE_LIMITS = { + MAX_FILE_SIZE: 10 * 1024 * 1024, // 10MB + MAX_FILES: 10, + ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp'], + ALLOWED_DOC_TYPES: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ALLOWED_SPREADSHEET_TYPES: ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], +} as const; + +/** + * Cache TTL (Time To Live) in seconds + */ +export const CACHE_TTL = { + SHORT: 60, // 1 minute + MEDIUM: 300, // 5 minutes + LONG: 3600, // 1 hour + DAY: 86400, // 24 hours +} as const; + +/** + * Date Formats + */ +export const DATE_FORMATS = { + ISO: 'YYYY-MM-DDTHH:mm:ss.sssZ', + DATE_ONLY: 'YYYY-MM-DD', + TIME_ONLY: 'HH:mm:ss', + DISPLAY_MX: 'DD/MM/YYYY', + DISPLAY_FULL_MX: 'DD/MM/YYYY HH:mm', } as const; diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/base.service.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/base.service.ts new file mode 100644 index 0000000..88eecb3 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/base.service.ts @@ -0,0 +1,217 @@ +/** + * BaseService - Abstract Service with Common CRUD Operations + * + * Provides multi-tenant aware CRUD operations using TypeORM. + * All domain services should extend this base class. + * + * @module @shared/services + */ + +import { + Repository, + FindOptionsWhere, + FindManyOptions, + DeepPartial, + ObjectLiteral, +} from 'typeorm'; + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export interface PaginatedResult { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface ServiceContext { + tenantId: string; + userId: string; +} + +export abstract class BaseService { + constructor(protected readonly repository: Repository) {} + + /** + * Find all records for a tenant with optional pagination + */ + async findAll( + ctx: ServiceContext, + options?: PaginationOptions & { where?: FindOptionsWhere } + ): Promise> { + const page = options?.page || 1; + const limit = options?.limit || 20; + const skip = (page - 1) * limit; + + const where = { + tenantId: ctx.tenantId, + deletedAt: null, + ...options?.where, + } as FindOptionsWhere; + + const [data, total] = await this.repository.findAndCount({ + where, + take: limit, + skip, + order: { createdAt: 'DESC' } as any, + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Find one record by ID for a tenant + */ + async findById(ctx: ServiceContext, id: string): Promise { + return this.repository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + deletedAt: null, + } as FindOptionsWhere, + }); + } + + /** + * Find one record by criteria + */ + async findOne( + ctx: ServiceContext, + where: FindOptionsWhere + ): Promise { + return this.repository.findOne({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...where, + } as FindOptionsWhere, + }); + } + + /** + * Find records by custom options + */ + async find( + ctx: ServiceContext, + options: FindManyOptions + ): Promise { + return this.repository.find({ + ...options, + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...(options.where || {}), + } as FindOptionsWhere, + }); + } + + /** + * Create a new record + */ + async create( + ctx: ServiceContext, + data: DeepPartial + ): Promise { + const entity = this.repository.create({ + ...data, + tenantId: ctx.tenantId, + createdById: ctx.userId, + } as DeepPartial); + + return this.repository.save(entity); + } + + /** + * Update an existing record + */ + async update( + ctx: ServiceContext, + id: string, + data: DeepPartial + ): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return null; + } + + const updated = this.repository.merge(existing, { + ...data, + updatedById: ctx.userId, + } as DeepPartial); + + return this.repository.save(updated); + } + + /** + * Soft delete a record + */ + async softDelete(ctx: ServiceContext, id: string): Promise { + const existing = await this.findById(ctx, id); + if (!existing) { + return false; + } + + await this.repository.update( + { id, tenantId: ctx.tenantId } as FindOptionsWhere, + { + deletedAt: new Date(), + deletedById: ctx.userId, + } as any + ); + + return true; + } + + /** + * Hard delete a record (use with caution) + */ + async hardDelete(ctx: ServiceContext, id: string): Promise { + const result = await this.repository.delete({ + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere); + + return (result.affected ?? 0) > 0; + } + + /** + * Count records + */ + async count( + ctx: ServiceContext, + where?: FindOptionsWhere + ): Promise { + return this.repository.count({ + where: { + tenantId: ctx.tenantId, + deletedAt: null, + ...where, + } as FindOptionsWhere, + }); + } + + /** + * Check if a record exists + */ + async exists( + ctx: ServiceContext, + where: FindOptionsWhere + ): Promise { + const count = await this.count(ctx, where); + return count > 0; + } +} diff --git a/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/index.ts b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/index.ts new file mode 100644 index 0000000..74d25cf --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/backend/src/shared/services/index.ts @@ -0,0 +1,5 @@ +/** + * Shared Services - Exports + */ + +export * from './base.service'; diff --git a/projects/erp-suite/apps/verticales/construccion/database/_MAP.md b/projects/erp-suite/apps/verticales/construccion/database/_MAP.md new file mode 100644 index 0000000..d0fb298 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/database/_MAP.md @@ -0,0 +1,306 @@ +# Database Map - ERP Construccion + +**Proyecto:** ERP Construccion +**Version:** 1.0 +**Ultima Actualizacion:** 2025-12-12 +**Total Schemas:** 7 +**Total Tablas:** 110 + +--- + +## NAVEGACION RAPIDA + +``` +database/ +├── _MAP.md # Este archivo (indice maestro) +├── schemas/ # DDL por schema +│ ├── 01-construction-schema-ddl.sql # 24 tablas +│ ├── 02-hr-schema-ddl.sql # 8 tablas +│ ├── 03-hse-schema-ddl.sql # 58 tablas +│ ├── 04-estimates-schema-ddl.sql # 8 tablas +│ ├── 05-infonavit-schema-ddl.sql # 8 tablas +│ ├── 06-inventory-ext-schema-ddl.sql # 4 tablas +│ └── 07-purchase-ext-schema-ddl.sql # 5 tablas +├── init-scripts/ # Scripts de inicializacion +│ └── 01-init-database.sql +├── migrations/ # Migraciones TypeORM +├── seeds/ # Datos de prueba +└── HERENCIA-ERP-CORE.md # Referencia a ERP-Core +``` + +--- + +## SCHEMAS OVERVIEW + +| # | Schema | Tablas | Descripcion | Estado | +|---|--------|--------|-------------|--------| +| 1 | `construction` | 24 | Proyectos, estructura, avances | ✅ DDL | +| 2 | `hr` | 8 | RRHH, asistencias, cuadrillas | ✅ DDL | +| 3 | `hse` | 58 | Seguridad, incidentes, EPP | ✅ DDL | +| 4 | `estimates` | 8 | Estimaciones, anticipos | ✅ DDL | +| 5 | `infonavit` | 8 | INFONAVIT, derechohabientes | ✅ DDL | +| 6 | `inventory` | 4 | Extension inventario obra | ✅ DDL | +| 7 | `purchase` | 5 | Extension compras obra | ✅ DDL | + +--- + +## DETALLE POR SCHEMA + +### 1. Schema: `construction` (24 tablas) + +**DDL:** `schemas/01-construction-schema-ddl.sql` + +#### Estructura de Proyecto (8 tablas) + +| Tabla | Descripcion | FK Principales | +|-------|-------------|----------------| +| `proyectos` | Proyectos/obras | `auth.tenants`, `auth.users` | +| `fraccionamientos` | Fraccionamientos | `proyectos` | +| `etapas` | Etapas de construccion | `fraccionamientos` | +| `manzanas` | Manzanas | `etapas` | +| `lotes` | Lotes/unidades | `manzanas`, `prototipos` | +| `torres` | Torres (vertical) | `fraccionamientos` | +| `niveles` | Niveles/pisos | `torres` | +| `departamentos` | Departamentos | `niveles`, `prototipos` | +| `prototipos` | Tipos de vivienda | `fraccionamientos` | + +#### Presupuestos (3 tablas) + +| Tabla | Descripcion | +|-------|-------------| +| `conceptos` | Catalogo de conceptos de obra | +| `presupuestos` | Presupuestos maestros | +| `presupuesto_partidas` | Partidas presupuestales | + +#### Programacion y Avances (5 tablas) + +| Tabla | Descripcion | +|-------|-------------| +| `programa_obra` | Programa general de obra | +| `programa_actividades` | Actividades programadas | +| `avances_obra` | Registro de avances | +| `fotos_avance` | Evidencias fotograficas | +| `bitacora_obra` | Bitacora de obra | + +#### Calidad (5 tablas) + +| Tabla | Descripcion | +|-------|-------------| +| `checklists` | Checklists de calidad | +| `checklist_items` | Items de checklist | +| `inspecciones` | Inspecciones de calidad | +| `inspeccion_resultados` | Resultados | +| `tickets_postventa` | Tickets de postventa | + +#### Contratos (3 tablas) + +| Tabla | Descripcion | +|-------|-------------| +| `subcontratistas` | Catalogo subcontratistas | +| `contratos` | Contratos de obra | +| `contrato_partidas` | Partidas contratadas | + +--- + +### 2. Schema: `hr` (8 tablas) + +**DDL:** `schemas/02-hr-schema-ddl.sql` + +| Tabla | Descripcion | Caracteristicas | +|-------|-------------|-----------------| +| `employees` | Empleados base | Extension de core | +| `employee_construction` | Extension construccion | Campos especificos | +| `puestos` | Catalogo de puestos | - | +| `asistencias` | Registro asistencias | GPS, biometrico | +| `asistencia_biometrico` | Datos biometricos | - | +| `geocercas` | Geocercas para GPS | PostGIS | +| `destajo` | Trabajo a destajo | - | +| `destajo_detalle` | Mediciones destajo | - | +| `cuadrillas` | Cuadrillas de trabajo | - | +| `cuadrilla_miembros` | Miembros de cuadrilla | - | +| `employee_fraccionamientos` | Asignacion a fracc | - | + +--- + +### 3. Schema: `hse` (58 tablas) + +**DDL:** `schemas/03-hse-schema-ddl.sql` + +#### Gestion de Incidentes (5 tablas) +- `incidentes`, `incidente_involucrados`, `incidente_acciones` +- `incidente_evidencias`, `incidente_causas` + +#### Control de Capacitaciones (6 tablas) +- `capacitaciones`, `capacitacion_participantes`, `capacitacion_materiales` +- `certificaciones`, `certificacion_empleados`, `plan_capacitacion` + +#### Inspecciones de Seguridad (7 tablas) +- `inspecciones_seguridad`, `inspeccion_hallazgos` +- `checklist_seguridad`, `checklist_seguridad_items` +- `areas_riesgo`, `rondas_seguridad`, `ronda_puntos` + +#### Control de EPP (7 tablas) +- `epp_catalogo`, `epp_asignaciones`, `epp_entregas` +- `epp_devoluciones`, `epp_inspecciones` +- `epp_vida_util`, `epp_stock` + +#### Cumplimiento STPS (11 tablas) +- `normas_stps`, `requisitos_norma`, `cumplimiento_norma` +- `auditorias_stps`, `auditoria_hallazgos` +- `planes_accion`, `acciones_correctivas` +- `comision_seguridad`, `comision_miembros` +- `recorridos_comision`, `actas_comision` + +#### Gestion Ambiental (9 tablas) +- `impactos_ambientales`, `residuos`, `residuo_movimientos` +- `manifiestos_residuos`, `monitoreo_ambiental` +- `permisos_ambientales`, `programas_ambientales` +- `indicadores_ambientales`, `eventos_ambientales` + +#### Permisos de Trabajo (8 tablas) +- `permisos_trabajo`, `permiso_riesgos`, `permiso_autorizaciones` +- `permisos_altura`, `permisos_caliente`, `permisos_confinado` +- `permisos_electrico`, `permisos_excavacion` + +#### Indicadores HSE (7 tablas) +- `kpi_configuracion`, `kpi_valores`, `kpi_metas` +- `dashboards_hse`, `alertas_hse` +- `reportes_hse`, `estadisticas_periodo` + +--- + +### 4. Schema: `estimates` (8 tablas) + +**DDL:** `schemas/04-estimates-schema-ddl.sql` + +| Tabla | Descripcion | +|-------|-------------| +| `estimaciones` | Estimaciones de obra | +| `estimacion_conceptos` | Conceptos estimados | +| `generadores` | Numeros generadores | +| `anticipos` | Anticipos de obra | +| `amortizaciones` | Amortizacion de anticipos | +| `retenciones` | Retenciones (garantia, IMSS) | +| `fondo_garantia` | Fondo de garantia | +| `estimacion_workflow` | Workflow de aprobacion | + +--- + +### 5. Schema: `infonavit` (8 tablas) + +**DDL:** `schemas/05-infonavit-schema-ddl.sql` + +| Tabla | Descripcion | +|-------|-------------| +| `registro_infonavit` | Registro RUV | +| `oferta_vivienda` | Oferta registrada | +| `derechohabientes` | Derechohabientes | +| `asignacion_vivienda` | Asignaciones | +| `actas` | Actas de entrega | +| `acta_viviendas` | Viviendas en acta | +| `reportes_infonavit` | Reportes RUV | +| `historico_puntos` | Historico puntos ecologicos | + +--- + +### 6. Schema: `inventory` Extension (4 tablas) + +**DDL:** `schemas/06-inventory-ext-schema-ddl.sql` + +| Tabla | Descripcion | +|-------|-------------| +| `almacenes_proyecto` | Almacenes por obra | +| `requisiciones_obra` | Requisiciones desde obra | +| `requisicion_lineas` | Lineas de requisicion | +| `consumos_obra` | Consumos por lote/concepto | + +--- + +### 7. Schema: `purchase` Extension (5 tablas) + +**DDL:** `schemas/07-purchase-ext-schema-ddl.sql` + +| Tabla | Descripcion | +|-------|-------------| +| `purchase_order_construction` | Extension OC | +| `supplier_construction` | Extension proveedores | +| `comparativo_cotizaciones` | Cuadro comparativo | +| `comparativo_proveedores` | Proveedores en comparativo | +| `comparativo_productos` | Productos cotizados | + +--- + +## ORDEN DE EJECUCION DDL + +```bash +# Prerequisito: ERP-Core debe estar instalado +# Schema auth.* y core.* deben existir + +# 1. Construction (base) +psql $DATABASE_URL -f schemas/01-construction-schema-ddl.sql + +# 2. HR (depende de construction) +psql $DATABASE_URL -f schemas/02-hr-schema-ddl.sql + +# 3. HSE (depende de construction y hr) +psql $DATABASE_URL -f schemas/03-hse-schema-ddl.sql + +# 4. Estimates (depende de construction) +psql $DATABASE_URL -f schemas/04-estimates-schema-ddl.sql + +# 5. INFONAVIT (depende de construction) +psql $DATABASE_URL -f schemas/05-infonavit-schema-ddl.sql + +# 6. Inventory Extension (depende de construction) +psql $DATABASE_URL -f schemas/06-inventory-ext-schema-ddl.sql + +# 7. Purchase Extension (depende de construction) +psql $DATABASE_URL -f schemas/07-purchase-ext-schema-ddl.sql +``` + +--- + +## RELACIONES PRINCIPALES + +``` +auth.tenants + └── construction.proyectos + └── construction.fraccionamientos + ├── construction.etapas + │ └── construction.manzanas + │ └── construction.lotes + ├── construction.torres (vertical) + │ └── construction.niveles + │ └── construction.departamentos + ├── hr.employee_fraccionamientos + │ └── hr.employees + └── hse.incidentes + └── hse.incidente_involucrados + └── hr.employees +``` + +--- + +## ENUMS UTILIZADOS + +Ver archivo: `backend/src/shared/constants/enums.constants.ts` + +Los principales enums estan definidos en: +- `PROJECT_STATUS` - Estados de proyecto +- `LOT_STATUS` - Estados de lote +- `INCIDENT_SEVERITY` - Severidad de incidentes +- `ESTIMATION_STATUS` - Estados de estimacion +- `INFONAVIT_ASSIGNMENT_STATUS` - Estados INFONAVIT + +--- + +## REFERENCIAS + +- **ERP-Core DDL:** `apps/erp-core/database/ddl/` +- **Herencia:** `HERENCIA-ERP-CORE.md` +- **Constantes SSOT:** `backend/src/shared/constants/database.constants.ts` + +--- + +**Mantenido por:** Architecture-Analyst +**Actualizacion:** Manual al agregar/modificar schemas diff --git a/projects/erp-suite/apps/verticales/construccion/devops/scripts/sync-enums.ts b/projects/erp-suite/apps/verticales/construccion/devops/scripts/sync-enums.ts new file mode 100644 index 0000000..42d97a9 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/devops/scripts/sync-enums.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env ts-node +/** + * Sync Enums - Backend to Frontend + * + * Este script sincroniza automaticamente las constantes y enums del backend + * al frontend, manteniendo el principio SSOT (Single Source of Truth). + * + * Ejecutar: npm run sync:enums + * + * @author Architecture-Analyst + * @date 2025-12-12 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// CONFIGURACION +// ============================================================================= + +const BACKEND_CONSTANTS_DIR = path.resolve(__dirname, '../../backend/src/shared/constants'); +const FRONTEND_CONSTANTS_DIR = path.resolve(__dirname, '../../frontend/web/src/shared/constants'); + +// Archivos a sincronizar +const FILES_TO_SYNC = [ + 'enums.constants.ts', + 'api.constants.ts', +]; + +// Header para archivos generados +const GENERATED_HEADER = `/** + * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY + * + * Este archivo es generado automaticamente desde el backend. + * Cualquier cambio sera sobreescrito en la proxima sincronizacion. + * + * Fuente: backend/src/shared/constants/ + * Generado: ${new Date().toISOString()} + * + * Para modificar, edita el archivo fuente en el backend + * y ejecuta: npm run sync:enums + */ + +`; + +// ============================================================================= +// FUNCIONES +// ============================================================================= + +function ensureDirectoryExists(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`📁 Created directory: ${dir}`); + } +} + +function processContent(content: string): string { + // Remover imports que no aplican al frontend + let processed = content + // Remover imports de Node.js + .replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]fs['"];?\n?/g, '') + .replace(/import\s+\*\s+as\s+\w+\s+from\s+['"]path['"];?\n?/g, '') + // Remover comentarios de @module backend + .replace(/@module\s+@shared\/constants\//g, '@module shared/constants/') + // Mantener 'as const' para inferencia de tipos + ; + + return GENERATED_HEADER + processed; +} + +function syncFile(filename: string): void { + const sourcePath = path.join(BACKEND_CONSTANTS_DIR, filename); + const destPath = path.join(FRONTEND_CONSTANTS_DIR, filename); + + if (!fs.existsSync(sourcePath)) { + console.log(`⚠️ Source file not found: ${sourcePath}`); + return; + } + + const content = fs.readFileSync(sourcePath, 'utf-8'); + const processedContent = processContent(content); + + fs.writeFileSync(destPath, processedContent); + console.log(`✅ Synced: ${filename}`); +} + +function generateIndexFile(): void { + const indexContent = `${GENERATED_HEADER} +// Re-export all constants +export * from './enums.constants'; +export * from './api.constants'; +`; + + const indexPath = path.join(FRONTEND_CONSTANTS_DIR, 'index.ts'); + fs.writeFileSync(indexPath, indexContent); + console.log(`✅ Generated: index.ts`); +} + +function main(): void { + console.log('🔄 Syncing constants from Backend to Frontend...\n'); + console.log(`Source: ${BACKEND_CONSTANTS_DIR}`); + console.log(`Target: ${FRONTEND_CONSTANTS_DIR}\n`); + + // Asegurar que el directorio destino existe + ensureDirectoryExists(FRONTEND_CONSTANTS_DIR); + + // Sincronizar cada archivo + for (const file of FILES_TO_SYNC) { + syncFile(file); + } + + // Generar archivo index + generateIndexFile(); + + console.log('\n✅ Sync completed successfully!'); + console.log('\nRecuerda importar las constantes desde:'); + console.log(' import { ROLES, PROJECT_STATUS, API_ROUTES } from "@/shared/constants";'); +} + +main(); diff --git a/projects/erp-suite/apps/verticales/construccion/devops/scripts/validate-constants-usage.ts b/projects/erp-suite/apps/verticales/construccion/devops/scripts/validate-constants-usage.ts new file mode 100644 index 0000000..1103de3 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/devops/scripts/validate-constants-usage.ts @@ -0,0 +1,385 @@ +#!/usr/bin/env ts-node +/** + * Validate Constants Usage - SSOT Enforcement + * + * Este script detecta hardcoding de schemas, tablas, rutas API y enums + * que deberian estar usando las constantes centralizadas del SSOT. + * + * Ejecutar: npm run validate:constants + * + * @author Architecture-Analyst + * @date 2025-12-12 + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +// ============================================================================= +// CONFIGURACION +// ============================================================================= + +interface ValidationPattern { + pattern: RegExp; + message: string; + severity: 'P0' | 'P1' | 'P2'; + suggestion: string; + exclude?: RegExp[]; +} + +const PATTERNS: ValidationPattern[] = [ + // Database Schemas + { + pattern: /['"`]auth['"`](?!\s*:)/g, + message: 'Hardcoded schema "auth"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.AUTH', + exclude: [/from\s+['"`]\.\/database\.constants['"`]/], + }, + { + pattern: /['"`]construction['"`](?!\s*:)/g, + message: 'Hardcoded schema "construction"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.CONSTRUCTION', + }, + { + pattern: /['"`]hr['"`](?!\s*:)(?!\.entity)/g, + message: 'Hardcoded schema "hr"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.HR', + }, + { + pattern: /['"`]hse['"`](?!\s*:)(?!\/)/g, + message: 'Hardcoded schema "hse"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.HSE', + }, + { + pattern: /['"`]estimates['"`](?!\s*:)/g, + message: 'Hardcoded schema "estimates"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.ESTIMATES', + }, + { + pattern: /['"`]infonavit['"`](?!\s*:)/g, + message: 'Hardcoded schema "infonavit"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.INFONAVIT', + }, + { + pattern: /['"`]inventory['"`](?!\s*:)/g, + message: 'Hardcoded schema "inventory"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.INVENTORY', + }, + { + pattern: /['"`]purchase['"`](?!\s*:)/g, + message: 'Hardcoded schema "purchase"', + severity: 'P0', + suggestion: 'Usa DB_SCHEMAS.PURCHASE', + }, + + // API Routes + { + pattern: /['"`]\/api\/v1\/proyectos['"`]/g, + message: 'Hardcoded API route "/api/v1/proyectos"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.PROYECTOS.BASE', + }, + { + pattern: /['"`]\/api\/v1\/fraccionamientos['"`]/g, + message: 'Hardcoded API route "/api/v1/fraccionamientos"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.FRACCIONAMIENTOS.BASE', + }, + { + pattern: /['"`]\/api\/v1\/employees['"`]/g, + message: 'Hardcoded API route "/api/v1/employees"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.EMPLOYEES.BASE', + }, + { + pattern: /['"`]\/api\/v1\/incidentes['"`]/g, + message: 'Hardcoded API route "/api/v1/incidentes"', + severity: 'P0', + suggestion: 'Usa API_ROUTES.INCIDENTES.BASE', + }, + + // Common Table Names + { + pattern: /FROM\s+proyectos(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "proyectos"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.CONSTRUCTION.PROYECTOS', + }, + { + pattern: /FROM\s+fraccionamientos(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "fraccionamientos"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.CONSTRUCTION.FRACCIONAMIENTOS', + }, + { + pattern: /FROM\s+employees(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "employees"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.HR.EMPLOYEES', + }, + { + pattern: /FROM\s+incidentes(?!\s+AS|\s+WHERE)/gi, + message: 'Hardcoded table name "incidentes"', + severity: 'P1', + suggestion: 'Usa DB_TABLES.HSE.INCIDENTES', + }, + + // Status Values + { + pattern: /status\s*===?\s*['"`]active['"`]/gi, + message: 'Hardcoded status "active"', + severity: 'P1', + suggestion: 'Usa PROJECT_STATUS.ACTIVE o USER_STATUS.ACTIVE', + }, + { + pattern: /status\s*===?\s*['"`]borrador['"`]/gi, + message: 'Hardcoded status "borrador"', + severity: 'P1', + suggestion: 'Usa BUDGET_STATUS.DRAFT o ESTIMATION_STATUS.DRAFT', + }, + { + pattern: /status\s*===?\s*['"`]aprobado['"`]/gi, + message: 'Hardcoded status "aprobado"', + severity: 'P1', + suggestion: 'Usa BUDGET_STATUS.APPROVED o ESTIMATION_STATUS.APPROVED', + }, + + // Role Names + { + pattern: /role\s*===?\s*['"`]admin['"`]/gi, + message: 'Hardcoded role "admin"', + severity: 'P0', + suggestion: 'Usa ROLES.ADMIN', + }, + { + pattern: /role\s*===?\s*['"`]supervisor['"`]/gi, + message: 'Hardcoded role "supervisor"', + severity: 'P1', + suggestion: 'Usa ROLES.SUPERVISOR_OBRA o ROLES.SUPERVISOR_HSE', + }, +]; + +// Archivos a excluir +const EXCLUDED_PATHS = [ + 'node_modules', + 'dist', + '.git', + 'coverage', + 'database.constants.ts', + 'api.constants.ts', + 'enums.constants.ts', + 'index.ts', + '.sql', + '.md', + '.json', + '.yml', + '.yaml', +]; + +// Extensiones a validar +const VALID_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; + +// ============================================================================= +// TIPOS +// ============================================================================= + +interface Violation { + file: string; + line: number; + column: number; + pattern: string; + message: string; + severity: 'P0' | 'P1' | 'P2'; + suggestion: string; + context: string; +} + +// ============================================================================= +// FUNCIONES +// ============================================================================= + +function shouldExclude(filePath: string): boolean { + return EXCLUDED_PATHS.some(excluded => filePath.includes(excluded)); +} + +function hasValidExtension(filePath: string): boolean { + return VALID_EXTENSIONS.some(ext => filePath.endsWith(ext)); +} + +function getFiles(dir: string): string[] { + const files: string[] = []; + + if (!fs.existsSync(dir)) { + return files; + } + + const items = fs.readdirSync(dir); + + for (const item of items) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + if (!shouldExclude(fullPath)) { + files.push(...getFiles(fullPath)); + } + } else if (stat.isFile() && hasValidExtension(fullPath) && !shouldExclude(fullPath)) { + files.push(fullPath); + } + } + + return files; +} + +function findViolations(filePath: string, content: string, patterns: ValidationPattern[]): Violation[] { + const violations: Violation[] = []; + const lines = content.split('\n'); + + for (const patternConfig of patterns) { + let match: RegExpExecArray | null; + const regex = new RegExp(patternConfig.pattern.source, patternConfig.pattern.flags); + + while ((match = regex.exec(content)) !== null) { + // Check exclusions + if (patternConfig.exclude) { + const shouldSkip = patternConfig.exclude.some(excludePattern => + excludePattern.test(content) + ); + if (shouldSkip) continue; + } + + // Find line number + const beforeMatch = content.substring(0, match.index); + const lineNumber = beforeMatch.split('\n').length; + const lineStart = beforeMatch.lastIndexOf('\n') + 1; + const column = match.index - lineStart + 1; + + violations.push({ + file: filePath, + line: lineNumber, + column, + pattern: match[0], + message: patternConfig.message, + severity: patternConfig.severity, + suggestion: patternConfig.suggestion, + context: lines[lineNumber - 1]?.trim() || '', + }); + } + } + + return violations; +} + +function formatViolation(v: Violation): string { + const severityColor = { + P0: '\x1b[31m', // Red + P1: '\x1b[33m', // Yellow + P2: '\x1b[36m', // Cyan + }; + const reset = '\x1b[0m'; + + return ` +${severityColor[v.severity]}[${v.severity}]${reset} ${v.message} + File: ${v.file}:${v.line}:${v.column} + Found: "${v.pattern}" + Context: ${v.context} + Suggestion: ${v.suggestion} +`; +} + +function generateReport(violations: Violation[]): void { + const p0 = violations.filter(v => v.severity === 'P0'); + const p1 = violations.filter(v => v.severity === 'P1'); + const p2 = violations.filter(v => v.severity === 'P2'); + + console.log('\n========================================'); + console.log('SSOT VALIDATION REPORT'); + console.log('========================================\n'); + + console.log(`Total Violations: ${violations.length}`); + console.log(` P0 (Critical): ${p0.length}`); + console.log(` P1 (High): ${p1.length}`); + console.log(` P2 (Medium): ${p2.length}`); + + if (violations.length > 0) { + console.log('\n----------------------------------------'); + console.log('VIOLATIONS FOUND:'); + console.log('----------------------------------------'); + + // Group by file + const byFile = violations.reduce((acc, v) => { + if (!acc[v.file]) acc[v.file] = []; + acc[v.file].push(v); + return acc; + }, {} as Record); + + for (const [file, fileViolations] of Object.entries(byFile)) { + console.log(`\n📁 ${file}`); + for (const v of fileViolations) { + console.log(formatViolation(v)); + } + } + } + + console.log('\n========================================'); + + if (p0.length > 0) { + console.log('\n❌ FAILED: P0 violations found. Fix before merging.\n'); + process.exit(1); + } else if (violations.length > 0) { + console.log('\n⚠️ WARNING: Non-critical violations found. Consider fixing.\n'); + process.exit(0); + } else { + console.log('\n✅ PASSED: No SSOT violations found!\n'); + process.exit(0); + } +} + +// ============================================================================= +// MAIN +// ============================================================================= + +function main(): void { + const backendDir = path.resolve(__dirname, '../../backend/src'); + const frontendDir = path.resolve(__dirname, '../../frontend/web/src'); + + console.log('🔍 Validating SSOT constants usage...\n'); + console.log(`Backend: ${backendDir}`); + console.log(`Frontend: ${frontendDir}`); + + const allViolations: Violation[] = []; + + // Scan backend + if (fs.existsSync(backendDir)) { + const backendFiles = getFiles(backendDir); + console.log(`\nScanning ${backendFiles.length} backend files...`); + + for (const file of backendFiles) { + const content = fs.readFileSync(file, 'utf-8'); + const violations = findViolations(file, content, PATTERNS); + allViolations.push(...violations); + } + } + + // Scan frontend + if (fs.existsSync(frontendDir)) { + const frontendFiles = getFiles(frontendDir); + console.log(`Scanning ${frontendFiles.length} frontend files...`); + + for (const file of frontendFiles) { + const content = fs.readFileSync(file, 'utf-8'); + const violations = findViolations(file, content, PATTERNS); + allViolations.push(...violations); + } + } + + generateReport(allViolations); +} + +main(); diff --git a/projects/erp-suite/apps/verticales/construccion/docker-compose.yml b/projects/erp-suite/apps/verticales/construccion/docker-compose.yml new file mode 100644 index 0000000..831ec51 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/docker-compose.yml @@ -0,0 +1,184 @@ +# Docker Compose - ERP Construccion +# Version: 1.0 +# Ambiente: Development + +version: '3.8' + +services: + # ========================================================================== + # DATABASE - PostgreSQL con PostGIS + # ========================================================================== + db: + image: postgis/postgis:15-3.3-alpine + container_name: construccion-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-construccion} + POSTGRES_PASSWORD: ${DB_PASSWORD:-construccion_dev_2024} + POSTGRES_DB: ${DB_NAME:-erp_construccion} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init-scripts:/docker-entrypoint-initdb.d:ro + ports: + - "${DB_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-construccion} -d ${DB_NAME:-erp_construccion}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - construccion-network + + # ========================================================================== + # REDIS - Cache y Colas + # ========================================================================== + redis: + image: redis:7-alpine + container_name: construccion-redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis_dev_2024} + volumes: + - redis_data:/data + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis_dev_2024}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - construccion-network + + # ========================================================================== + # BACKEND - API Node.js + Express + # ========================================================================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + target: ${BUILD_TARGET:-development} + container_name: construccion-backend + restart: unless-stopped + environment: + NODE_ENV: ${NODE_ENV:-development} + APP_PORT: 3000 + API_VERSION: v1 + # Database + DB_HOST: db + DB_PORT: 5432 + DB_USER: ${DB_USER:-construccion} + DB_PASSWORD: ${DB_PASSWORD:-construccion_dev_2024} + DB_NAME: ${DB_NAME:-erp_construccion} + # Redis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_dev_2024} + # JWT + JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-1d} + JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d} + # CORS + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173,http://localhost:3001} + CORS_CREDENTIALS: "true" + # Logging + LOG_LEVEL: ${LOG_LEVEL:-debug} + LOG_FORMAT: dev + volumes: + - ./backend/src:/app/src:ro + - ./backend/package.json:/app/package.json:ro + - backend_node_modules:/app/node_modules + ports: + - "${BACKEND_PORT:-3000}:3000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - construccion-network + + # ========================================================================== + # FRONTEND WEB - React + Vite + # ========================================================================== + frontend: + build: + context: ./frontend/web + dockerfile: Dockerfile + target: ${BUILD_TARGET:-development} + container_name: construccion-frontend + restart: unless-stopped + environment: + VITE_API_URL: ${VITE_API_URL:-http://localhost:3000/api/v1} + VITE_WS_URL: ${VITE_WS_URL:-ws://localhost:3000} + volumes: + - ./frontend/web/src:/app/src:ro + - ./frontend/web/public:/app/public:ro + - ./frontend/web/index.html:/app/index.html:ro + - frontend_node_modules:/app/node_modules + ports: + - "${FRONTEND_PORT:-5173}:5173" + depends_on: + - backend + networks: + - construccion-network + + # ========================================================================== + # ADMINER - Database Management (Development Only) + # ========================================================================== + adminer: + image: adminer:4-standalone + container_name: construccion-adminer + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: pepa-linha-dark + ports: + - "${ADMINER_PORT:-8080}:8080" + depends_on: + - db + profiles: + - dev + networks: + - construccion-network + + # ========================================================================== + # MAILHOG - Email Testing (Development Only) + # ========================================================================== + mailhog: + image: mailhog/mailhog:latest + container_name: construccion-mailhog + restart: unless-stopped + ports: + - "${MAILHOG_SMTP_PORT:-1025}:1025" + - "${MAILHOG_WEB_PORT:-8025}:8025" + profiles: + - dev + networks: + - construccion-network + +# ========================================================================== +# VOLUMES +# ========================================================================== +volumes: + postgres_data: + driver: local + redis_data: + driver: local + backend_node_modules: + driver: local + frontend_node_modules: + driver: local + +# ========================================================================== +# NETWORKS +# ========================================================================== +networks: + construccion-network: + driver: bridge diff --git a/projects/erp-suite/apps/verticales/construccion/docs/ANALISIS-IMPLEMENTACION-ARQUITECTURA.md b/projects/erp-suite/apps/verticales/construccion/docs/ANALISIS-IMPLEMENTACION-ARQUITECTURA.md new file mode 100644 index 0000000..d48302b --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/docs/ANALISIS-IMPLEMENTACION-ARQUITECTURA.md @@ -0,0 +1,485 @@ +# Analisis de Implementacion - ERP Construccion + +**Documento:** Analisis de Implementacion Arquitectonico +**Proyecto:** erp-suite/verticales/construccion +**Base:** erp-core (Documentacion de Referencia) +**Fecha:** 2025-12-12 +**Analista:** Architecture-Analyst +**Estado:** Completado + +--- + +## RESUMEN EJECUTIVO + +### Contexto del Analisis + +Se realizo un analisis exhaustivo del subproyecto **ERP Construccion** comparando: +1. La documentacion generada para **erp-core** (base generica) +2. El proyecto de referencia de **Odoo** (14 modulos analizados) +3. La logica de negocio especifica del **giro de construccion de vivienda** + +### Hallazgos Principales + +| Metrica | Valor | Estado | +|---------|-------|--------| +| **Progreso General** | 35% | En desarrollo | +| **Documentacion** | 449 archivos MD | 100% completa | +| **DDL/Schemas Implementados** | 3 de 7 (33 tablas) | 50% | +| **Backend Implementado** | 4 de 18 modulos | 22% | +| **Frontend Implementado** | Estructura base | 5% | +| **Gaps Funcionales Identificados** | 42 | 18 criticos (P0) | +| **Mejoras Arquitectonicas Requeridas** | 15 | 10 criticas (P0) | + +--- + +## 1. ESTADO ACTUAL DEL PROYECTO + +### 1.1 Documentacion (100% Completa) + +La documentacion del proyecto es extensa y bien estructurada: + +| Tipo | Cantidad | +|------|----------| +| Requerimientos Funcionales (RF) | 87 | +| Especificaciones Tecnicas (ET) | 78 | +| Historias de Usuario (US) | 149 | +| Story Points | 692 | +| ADRs | 12 | + +**Modulos Documentados (18 total):** + +**Fase 1 - MAI (14 modulos):** +- MAI-001: Fundamentos (Auth, RBAC, Multi-tenancy) +- MAI-002: Proyectos y Estructura +- MAI-003: Presupuestos y Costos +- MAI-004: Compras e Inventarios +- MAI-005: Control de Obra y Avances +- MAI-006: Reportes y Analytics +- MAI-007: RRHH y Asistencias (GPS + Biometrico) +- MAI-008: Estimaciones y Facturacion +- MAI-009: Calidad y Postventa +- MAI-010: CRM Derechohabientes +- MAI-011: Integracion INFONAVIT +- MAI-012: Contratos +- MAI-013: Administracion +- MAI-018: Preconstruccion y Licitaciones + +**Fase 2 - MAE (3 modulos):** +- MAE-014: Finanzas y Controlling +- MAE-015: Activos y Maquinaria +- MAE-016: Gestion Documental + +**Fase 3 - MAA (1 modulo):** +- MAA-017: Seguridad HSE (58 tablas, IA predictiva) + +### 1.2 Implementacion Actual + +#### Base de Datos + +| Schema | Estado | Tablas | ENUMs | +|--------|--------|--------|-------| +| `construction` | DDL Listo | 2 | - | +| `hr` | DDL Listo | 3 | - | +| `hse` | DDL Listo | 28 | 67 | +| `estimates` | Pendiente | 8 (doc) | - | +| `infonavit` | Pendiente | 8 (doc) | - | +| `inventory-ext` | Pendiente | 4 (doc) | - | +| `purchase-ext` | Pendiente | 5 (doc) | - | + +**Total Implementado:** 33 tablas de 110 documentadas (30%) + +#### Backend + +``` +backend/src/modules/ +├── construction/ ✅ Entities + Services + Controllers +│ ├── proyecto.entity.ts +│ └── fraccionamiento.entity.ts +├── hr/ ✅ Entities basicas +│ ├── employee.entity.ts +│ ├── puesto.entity.ts +│ └── employee-fraccionamiento.entity.ts +├── hse/ ✅ Entities basicas +│ ├── incidente.entity.ts +│ ├── incidente-involucrado.entity.ts +│ ├── incidente-accion.entity.ts +│ └── capacitacion.entity.ts +└── core/ ✅ Base multi-tenant + ├── user.entity.ts + └── tenant.entity.ts +``` + +**Implementado:** 4 modulos de 18 (22%) + +--- + +## 2. VALIDACION CONTRA ERP-CORE + +### 2.1 Patron de Herencia + +ERP Construccion opera como **proyecto independiente** que: +- ✅ Implementa schemas propios basados en patrones del core +- ✅ Adapta estructuras al dominio de construccion +- ✅ Reutiliza codigo donde tiene sentido +- ✅ Opera independientemente del ERP-Core + +### 2.2 Patrones del Core Adoptados + +| Patron Core | Adaptacion en Construccion | Estado | +|-------------|---------------------------|--------| +| `auth.*` | Autenticacion multi-tenant | ✅ Definido | +| `core.partners` | Contratistas, proveedores | ✅ Definido | +| `inventory.*` | Materiales de construccion | ⏳ Pendiente | +| `projects.*` | Obras, fraccionamientos | ✅ Implementado | +| `hr.*` | Personal de obra, cuadrillas | ✅ Implementado | +| `financial.*` | Contabilidad analitica | ⏳ Pendiente | + +### 2.3 Specs del Core Aplicables + +| SPEC | Aplicacion | Estado | +|------|------------|--------| +| SPEC-VALORACION-INVENTARIO | Costeo de materiales | ✅ DDL Listo | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Lotes de concreto, acero | ✅ DDL Listo | +| SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN | Partidas de obra | ⏳ Pendiente | +| SPEC-PRESUPUESTOS-REVISIONES | Control presupuestal | ⏳ Pendiente | +| SPEC-MAIL-THREAD-TRACKING | Historial de presupuestos | ⏳ Pendiente | + +--- + +## 3. VALIDACION CONTRA ODOO + +### 3.1 Matriz de Mapeo por Modulo + +| Modulo | Fuente Odoo | Estrategia | Completitud | +|--------|-------------|------------|-------------| +| MAI-001 Auth | `auth_signup`, `base` | Adaptar (JWT vs sesiones) | 80% | +| MAI-002 Proyectos | `project.project` | Adoptar + Extender | 60% | +| MAI-003 Presupuestos | `account.budget` | Crear nuevo (APU mexicano) | 30% | +| MAI-004 Compras | `purchase.order` | Adoptar patron | 40% | +| MAI-005 Avances | No existe | 100% Crear nuevo | 25% | +| MAI-006 Finanzas | `account.*` | Adaptar (CFDI) | 20% | +| MAI-007 RRHH | `hr.employee` | Adoptar + Extender | 50% | +| MAI-008 Estimaciones | No existe | 100% Crear nuevo | 10% | +| MAI-009 Calidad | `quality.*` | Adoptar patron | 15% | +| MAI-010 CRM | `portal` | Referencia (UI diferente) | 10% | +| MAI-011 INFONAVIT | No existe | 100% Crear nuevo | 5% | + +### 3.2 Patrones Arquitectonicos Adoptados de Odoo + +| Patron | Descripcion | Estado en Construccion | +|--------|-------------|----------------------| +| **State Machine** | Estados en documentos (draft->done) | ✅ Implementado parcialmente | +| **Analytic Distribution** | Costos por proyecto | ⏳ Pendiente (GAP CRITICO) | +| **Mail Thread** | Tracking automatico de cambios | ⏳ Pendiente (GAP CRITICO) | +| **Computed Fields** | Campos calculados con store | ✅ Implementado | +| **Record Rules** | RLS dinamico por rol | ⏳ Pendiente (GAP CRITICO) | + +### 3.3 Modulos Odoo No Utilizados (Correctamente Excluidos) + +- `website`, `website_sale` - Frontend custom en React +- `crm` - No requerido para construccion +- `mrp` - Manufactura no aplica +- `pos` - No requerido +- `l10n_*` - Localizacion propia para Mexico + +--- + +## 4. GAPS FUNCIONALES IDENTIFICADOS + +### 4.1 Gaps P0 (CRITICOS) - 18 Items + +#### Gaps de Odoo (Logica de Negocio) + +| # | Funcionalidad | Impacto | Esfuerzo | +|---|---------------|---------|----------| +| 1 | Contabilidad Analitica Universal | CRITICO | 3-4 sem | +| 2 | Reportes Financieros Estandar (P&L, Balance) | CRITICO | 2 sem | +| 3 | Sistema Tracking Automatico (mail.thread) | CRITICO | 2-3 sem | +| 4 | Portal de Clientes | CRITICO | 3 sem | +| 5 | Reportes P&L por Proyecto | CRITICO | 2 sem | +| 6 | Budget vs Real por Proyecto | CRITICO | 2 sem | + +#### Gaps de Arquitectura (DevOps) + +| # | Aspecto | Impacto | Esfuerzo | +|---|---------|---------|----------| +| 7 | Sistema SIMCO (_MAP.md) | CRITICO | 2 sem | +| 8 | 159 RLS Policies | CRITICO | 4 sem | +| 9 | Backend SSOT | CRITICO | 1-2 sem | +| 10 | Script sync-enums.ts | CRITICO | 1 sem | +| 11 | Script validate-constants-usage.ts | CRITICO | 1 sem | +| 12 | Docker + docker-compose | CRITICO | 1 sem | +| 13 | CI/CD (GitHub Actions) | CRITICO | 2 sem | +| 14 | Test Coverage 70%+ | CRITICO | 6-8 sem | +| 15 | Feature-Sliced Design Frontend | CRITICO | 3-4 sem | +| 16 | ORM (TypeORM completo) | ALTO | 3 sem | +| 17 | Zustand State Management | ALTO | 1 sem | +| 18 | Path Aliases (@shared, @modules) | ALTO | 1 dia | + +**Esfuerzo Total P0:** 27-35 semanas (~7-9 meses) + +### 4.2 Gaps P1 (ALTA PRIORIDAD) - 15 Items + +- Multi-moneda con tasas de cambio +- Conciliacion bancaria automatica +- 2FA para usuarios criticos +- API Keys para integraciones +- Timesheet (horas por proyecto) +- Firma electronica de documentos +- Chatter UI para historico +- Seguimiento pagos a proveedores +- Acuerdos de compra (Blanket Orders) +- Estrategias de inventario (FIFO, Avg Cost) + +--- + +## 5. LOGICA DE NEGOCIO ESPECIFICA DEL GIRO + +### 5.1 Componentes 100% Especificos de Construccion + +Estos componentes son **unicos del giro** y NO deben migrarse al core generico: + +#### Base de Datos (30 tablas especificas - 43%) + +| Schema | Tablas | Razon | +|--------|--------|-------| +| `project_management` | 8 | Estructura de fraccionamientos INFONAVIT | +| `construction_management` | 8 | Curva S, avances fisicos, WBS | +| `quality_management` | 6 | QA especifico construccion | +| `infonavit_management` | 7 | Regulacion Mexico INFONAVIT | +| `estimates` | 1 | Generadores de obra | + +#### Logica de Negocio Unica + +| Calculo | Descripcion | Especificidad | +|---------|-------------|---------------| +| **Curva S** | Programacion de obra | 100% Construccion | +| **Avance Fisico** | % ponderado por concepto | 100% Construccion | +| **Explosion de Insumos** | APU (Analisis Precios Unitarios) | 100% Construccion | +| **Estimacion de Obra** | Generadores con numeros generadores | 100% Construccion | +| **Presupuesto por m2** | Calculo por prototipo de vivienda | 100% Construccion | + +#### Workflows Especificos + +| Workflow | Descripcion | +|----------|-------------| +| Licitacion → Obra → Entrega | Ciclo vida proyecto construccion | +| Asignacion de Lotes | Proceso INFONAVIT derechohabiente→lote | +| Estimacion → Pago | Flujo con retenciones, fondo garantia | +| Inspeccion → Entrega | QA + acta entrega-recepcion | + +#### Validaciones Especificas Mexico + +| Validacion | Descripcion | +|------------|-------------| +| NSS valido | Numero Seguro Social IMSS | +| Credito INFONAVIT | Monto credito vs precio vivienda | +| CFDI | Facturacion electronica SAT | +| SUA | Sistema Unico Autodeterminacion | +| ISN | Impuesto sobre nomina estatal | + +### 5.2 Integraciones Externas Requeridas + +| Integracion | API | Prioridad | +|-------------|-----|-----------| +| IMSS | SOAP/REST + Certificado | P0 | +| INFONAVIT | REST + OAuth 2.0 | P0 | +| SAT (CFDI) | PAC integration | P1 | +| WhatsApp Business | Webhook + API | P1 | +| Bancos | APIs para conciliacion | P2 | + +--- + +## 6. MEJORAS ARQUITECTONICAS RECOMENDADAS + +### 6.1 Top 10 Mejoras Priorizadas + +| # | Mejora | Fuente | Prioridad | Esfuerzo | ROI | +|---|--------|--------|-----------|----------|-----| +| 1 | Sistema SSOT completo | Gamilit | P0 | 1-2 sem | ALTO | +| 2 | Multi-Schema DB organizado | Gamilit | P0 | 2 sem | ALTO | +| 3 | Contabilidad Analitica Universal | Odoo | P0 | 3-4 sem | ALTO | +| 4 | Sistema Tracking Automatico | Odoo | P0 | 2-3 sem | ALTO | +| 5 | Docker + docker-compose | Best Practice | P0 | 1 sem | ALTO | +| 6 | CI/CD (GitHub Actions) | Best Practice | P0 | 2 sem | ALTO | +| 7 | Test Coverage 70%+ | Best Practice | P0 | 6-8 sem | ALTO | +| 8 | Feature-Sliced Design Frontend | Gamilit | P0 | 3-4 sem | ALTO | +| 9 | Portal Usuarios Externos | Odoo | P0 | 3 sem | ALTO | +| 10 | Record Rules en RLS | Odoo | P0 | 2 sem | ALTO | + +### 6.2 Arquitectura Objetivo + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ERP CONSTRUCCION │ +├─────────────────────────────────────────────────────────────────┤ +│ Frontend (React 18 + Vite + TypeScript) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ shared/ │ │features/│ │ pages/ │ │ app/ │ │ +│ │ 180+ │ │director/│ │ │ │ │ │ +│ │ comps │ │resident/│ │ │ │ │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ API REST (Express.js + TypeScript) │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ auth | projects | budgets | purchases | progress | hr | hse ││ +│ └─────────────────────────────────────────────────────────────┘│ +├─────────────────────────────────────────────────────────────────┤ +│ Database (PostgreSQL 15+ con RLS) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ auth │ │construc │ │ hr │ │ hse │ │estimates│ │ +│ │ 10 tbl │ │ 24 tbl │ │ 8 tbl │ │ 58 tbl │ │ 8 tbl │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ ┌─────────┐ ┌─────────┐ │ +│ │infonavit│ │ inv-ext │ Total: 7 schemas, 110+ tablas │ +│ │ 8 tbl │ │ 4 tbl │ │ +│ └─────────┘ └─────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. PLAN DE IMPLEMENTACION RECOMENDADO + +### 7.1 Fase 1: Fundamentos (Semanas 1-4) + +**Objetivo:** Establecer arquitectura solida + +| Semana | Actividades | +|--------|-------------| +| 1-2 | SSOT System, Path Aliases, Scripts de validacion | +| 3-4 | Docker + CI/CD basico, Reorganizar schemas | + +**Entregables:** +- [ ] SSOT implementado (validado con script) +- [ ] Docker funcional (`docker-compose up` exitoso) +- [ ] CI/CD basico con validaciones + +### 7.2 Fase 2: Backend Core (Semanas 5-12) + +**Objetivo:** Completar modulos P0 + +| Semana | Actividades | +|--------|-------------| +| 5-6 | DDL completo (estimates, infonavit, inventory-ext, purchase-ext) | +| 7-8 | MAI-003 Presupuestos y Costos backend | +| 9-10 | MAI-005 Control de Obra backend | +| 11-12 | MAI-008 Estimaciones backend | + +**Entregables:** +- [ ] 7 schemas DDL completos (110 tablas) +- [ ] Backend 8 modulos funcionales + +### 7.3 Fase 3: Mejoras Arquitectonicas (Semanas 13-20) + +**Objetivo:** Implementar gaps criticos + +| Semana | Actividades | +|--------|-------------| +| 13-16 | Contabilidad analitica universal | +| 17-18 | Sistema tracking automatico (mail.thread) | +| 19-20 | Portal usuarios externos | + +**Entregables:** +- [ ] Reportes P&L por proyecto automaticos +- [ ] Auditoria automatica de cambios +- [ ] Portal derechohabientes funcional + +### 7.4 Fase 4: Testing y Estabilizacion (Semanas 21-28) + +**Objetivo:** Alcanzar test coverage 70%+ + +| Semana | Actividades | +|--------|-------------| +| 21-24 | Unit tests (objetivo 80% coverage) | +| 25-26 | Integration tests (objetivo 70%) | +| 27-28 | E2E tests flujos criticos | + +**Entregables:** +- [ ] Test coverage 70%+ global +- [ ] Pipeline CI/CD completo +- [ ] Deployment automatizado a staging + +--- + +## 8. METRICAS DE EXITO + +### 8.1 KPIs Tecnicos + +| Metrica | Actual | Objetivo | Plazo | +|---------|--------|----------|-------| +| DDL Completo | 30% | 100% | 2 meses | +| Backend Modulos | 22% | 80% | 4 meses | +| Frontend Modulos | 5% | 60% | 6 meses | +| Test Coverage | ~15% | 70%+ | 7 meses | +| Documentacion | 100% | 100% | Mantener | + +### 8.2 KPIs de Negocio + +| Metrica | Objetivo | +|---------|----------| +| Reduccion tiempo reportes | -70% | +| Bugs en produccion | -70% | +| Velocidad desarrollo | +40% | +| Satisfaccion cliente | +30% | + +--- + +## 9. RIESGOS Y MITIGACION + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|-------------|---------|------------| +| Regresiones en integracion | MEDIA | ALTO | Testing exhaustivo, feature flags | +| Integraciones IMSS/INFONAVIT complejas | ALTA | ALTO | Sandbox desde Sprint 1 | +| Certificados IMSS dificiles de obtener | MEDIA | ALTO | Solicitar al inicio | +| APIs gubernamentales inestables | MEDIA | MEDIO | Retry logic, fallbacks | +| Resistencia al cambio equipo | MEDIA | MEDIO | Capacitacion, quick wins | + +--- + +## 10. CONCLUSIONES + +### 10.1 Fortalezas del Proyecto + +1. **Documentacion excelente** - 449 archivos MD, 100% completa +2. **Vision clara** - 18 modulos definidos con 692 story points +3. **Arquitectura SaaS Multi-tenant** - Modelo de negocio validado +4. **Logica de negocio especifica bien definida** - INFONAVIT, HSE, Estimaciones + +### 10.2 Areas de Mejora Criticas + +1. **Implementacion atrasada** - Solo 35% progreso general +2. **DevOps inexistente** - Sin Docker, CI/CD, test coverage bajo +3. **Gaps arquitectonicos** - SSOT, RLS policies, tracking automatico +4. **Integraciones pendientes** - IMSS, INFONAVIT, CFDI + +### 10.3 Recomendacion Final + +**PRIORIZAR implementacion de fundamentos arquitectonicos (SSOT, Docker, CI/CD) antes de continuar con nuevos modulos.** + +Razones: +1. Evita deuda tecnica acumulada +2. Facilita desarrollo paralelo del equipo +3. Garantiza calidad desde el inicio +4. ROI alto en todas las mejoras P0 + +**Tiempo estimado para alcanzar 80% funcionalidad:** 7-9 meses + +--- + +## REFERENCIAS + +- GAP-ANALYSIS.md - Analisis de brechas funcionales +- COMPONENTES-ESPECIFICOS.md - Componentes no migrables +- MEJORAS-ARQUITECTONICAS.md - Mejoras recomendadas +- RETROALIMENTACION.md - Feedback consolidado +- HERENCIA-ERP-CORE.md - Relacion con core +- ODOO-CONSTRUCCION-MAPPING.md - Mapeo con Odoo + +--- + +**Documento generado por:** Architecture-Analyst +**Fecha:** 2025-12-12 +**Version:** 1.0 +**Estado:** Completado +**Proxima revision:** Post-aprobacion del roadmap diff --git a/projects/erp-suite/apps/verticales/construccion/frontend/web/Dockerfile b/projects/erp-suite/apps/verticales/construccion/frontend/web/Dockerfile new file mode 100644 index 0000000..c8d635d --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/frontend/web/Dockerfile @@ -0,0 +1,65 @@ +# ============================================================================= +# Dockerfile - Frontend Web +# ERP Construccion - React + Vite + TypeScript +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Base +# ----------------------------------------------------------------------------- +FROM node:20-alpine AS base + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# ----------------------------------------------------------------------------- +# Stage 2: Development +# ----------------------------------------------------------------------------- +FROM base AS development + +# Install all dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Expose Vite dev server port +EXPOSE 5173 + +# Development command with hot reload +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] + +# ----------------------------------------------------------------------------- +# Stage 3: Builder +# ----------------------------------------------------------------------------- +FROM base AS builder + +# Install all dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build for production +RUN npm run build + +# ----------------------------------------------------------------------------- +# Stage 4: Production (with nginx) +# ----------------------------------------------------------------------------- +FROM nginx:alpine AS production + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/projects/erp-suite/apps/verticales/construccion/frontend/web/nginx.conf b/projects/erp-suite/apps/verticales/construccion/frontend/web/nginx.conf new file mode 100644 index 0000000..c5515d2 --- /dev/null +++ b/projects/erp-suite/apps/verticales/construccion/frontend/web/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA routing - send all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # API proxy (optional - if needed) + # location /api { + # proxy_pass http://backend:3000; + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection 'upgrade'; + # proxy_set_header Host $host; + # proxy_cache_bypass $http_upgrade; + # } +} diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/PROJECT-STATUS.md b/projects/erp-suite/apps/verticales/mecanicas-diesel/PROJECT-STATUS.md index e645d2e..62a13d1 100644 --- a/projects/erp-suite/apps/verticales/mecanicas-diesel/PROJECT-STATUS.md +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/PROJECT-STATUS.md @@ -1,21 +1,22 @@ # ESTADO DEL PROYECTO - ERP Mecánicas Diesel **Proyecto:** ERP Mecánicas Diesel (Proyecto Independiente) -**Estado:** Documentación COMPLETA (MVP) - DDL IMPLEMENTADO -**Progreso:** 95% -**Última actualización:** 2025-12-08 +**Estado:** Documentacion COMPLETA + GAPs RESUELTOS - Listo para desarrollo +**Progreso:** 100% +**Ultima actualizacion:** 2025-12-12 --- ## RESUMEN -- **Tipo:** Proyecto independiente que adapta patrones del ERP-Core -- **Fase actual:** Documentación completa + DDL - Listo para desarrollo backend/frontend -- **Épicas documentadas:** 6/6 (MVP completo) -- **Módulos documentados:** 6/6 (MVP completo) +- **Tipo:** Proyecto independiente - Sistema ERP para talleres diesel +- **Fase actual:** Documentacion completa + DDL - Listo para desarrollo +- **Epicas documentadas:** 6/6 (MVP completo) +- **Modulos documentados:** 6/6 (MVP completo) - **Story Points totales:** 241 SP - **Historias de usuario:** 55 historias detalladas (100% cobertura) -- **Schemas de BD:** 4/4 DDL implementados (43 tablas) +- **Schemas de BD:** 7 schemas DDL implementados (65+ tablas) +- **Validacion arquitectonica:** Completada --- @@ -166,40 +167,83 @@ docs/03-modelo-datos/ --- -## SCHEMAS DE BASE DE DATOS (4) +## SCHEMAS DE BASE DE DATOS (7) -| Schema | Tablas | Descripcion | -|--------|--------|-------------| -| workshop_core | 9 | Configuracion, usuarios, clientes, servicios | -| service_management | 14 | Ordenes, diagnosticos, cotizaciones | -| parts_management | 12 | Inventario, refacciones, movimientos | -| vehicle_management | 8 | Vehiculos, flotas, motores | +| Schema | Tablas | Descripcion | DDL | +|--------|--------|-------------|-----| +| workshop_core | 9 | Configuracion, usuarios, clientes, servicios | 01-create-schemas.sql | +| service_management | 14+ | Ordenes, diagnosticos, cotizaciones, firma | 03-service-management.sql, 11-quote-signature.sql | +| parts_management | 12+ | Inventario, refacciones, garantias | 04-parts-management.sql, 10-warranty-claims.sql | +| vehicle_management | 8 | Vehiculos, flotas, motores | 05-vehicle-management.sql | +| notifications | 6 | Tracking, followers, actividades | 07-notifications-schema.sql | +| analytics | 4 | Contabilidad analitica, P&L por orden | 08-analytics-schema.sql | +| purchasing | 5 | Ordenes de compra, proveedores, recepciones | 09-purchasing-schema.sql | -**Total:** 43 tablas con RLS multi-tenant +**Total:** 65+ tablas con RLS multi-tenant + +### Archivos DDL + +``` +database/init/ +├── 00-extensions.sql # Extensiones PostgreSQL +├── 01-create-schemas.sql # Creacion de schemas +├── 02-rls-functions.sql # Funciones RLS multi-tenant +├── 03-service-management-tables.sql # Ordenes, diagnosticos +├── 04-parts-management-tables.sql # Inventario, refacciones +├── 05-vehicle-management-tables.sql # Vehiculos, flotas +├── 06-seed-data.sql # Datos iniciales +├── 07-notifications-schema.sql # Tracking, followers, actividades +├── 08-analytics-schema.sql # Contabilidad analitica +├── 09-purchasing-schema.sql # Compras y proveedores +├── 10-warranty-claims.sql # Garantias de refacciones +└── 11-quote-signature.sql # Firma electronica basica +``` + +--- + +## FUNCIONALIDADES ADICIONALES IMPLEMENTADAS + +| Funcionalidad | Descripcion | DDL | +|---------------|-------------|-----| +| Sistema de tracking | Historial de cambios en documentos | 07-notifications-schema.sql | +| Followers/suscriptores | Notificaciones automaticas | 07-notifications-schema.sql | +| Actividades programadas | Recordatorios y tareas | 07-notifications-schema.sql | +| Contabilidad analitica | P&L por orden de servicio | 08-analytics-schema.sql | +| Gestion de compras | Ordenes de compra y RFQ | 09-purchasing-schema.sql | +| Tracking de garantias | Control de garantias de refacciones | 10-warranty-claims.sql | +| Firma electronica | Aprobacion de cotizaciones | 11-quote-signature.sql | + +**Funcionalidades para Fase 2:** +- MMD-007 Facturacion integrada (CFDI) +- Portal de clientes --- ## PROXIMOS PASOS -1. **Crear especificaciones tecnicas (ET)** - ET por modulo con endpoints y UI -2. **Esperar erp-core** - Depende de MGN-001 a MGN-011 -3. **Iniciar desarrollo** - Sprint 1 con MMD-001 +1. **Iniciar desarrollo backend** - APIs REST con NestJS +2. **Crear especificaciones tecnicas (ET)** - ET por modulo con endpoints y UI +3. **Implementar modulo MMD-007 Facturacion** - Fase 2 con CFDI --- ## ARQUITECTURA -**Tipo:** Proyecto Independiente (fork conceptual del ERP-Core) +**Tipo:** Proyecto Independiente - ERP Vertical para Talleres Diesel -**Patrones reutilizados del ERP-Core:** -- Multi-tenancy con RLS -- Estructura de autenticación -- Patrones de inventario +**Stack Tecnologico:** +- **Base de datos:** PostgreSQL 15+ con RLS multi-tenant +- **Backend:** Node.js + NestJS + TypeScript +- **Frontend:** React + TypeScript +- **Autenticacion:** JWT + RBAC -**Opera de forma autónoma:** No requiere ERP-Core instalado +**Patrones Implementados:** +- Multi-tenancy con Row Level Security (RLS) +- Arquitectura modular por dominio +- Sistema de tracking y notificaciones +- Contabilidad analitica por orden -**Referencia:** -- Vertical Construcción (patrones de documentación aplicados) +**Opera de forma autonoma:** Sistema standalone sin dependencias externas --- @@ -211,12 +255,12 @@ docs/03-modelo-datos/ | Story Points | 241 SP | | Historias detalladas | 55 | | Cobertura US | 100% | -| Schemas BD | 4 completos | -| Tablas BD | 43 | +| Schemas BD | 7 completos | +| Tablas BD | 65+ | +| Funcionalidades adicionales | 7 implementadas | | Sprints estimados | 10 | -| Reutilizacion Core | 60-70% | --- *Proyecto parte de ERP Suite - Fabrica de Software con Agentes IA* -*Ultima actualizacion: 2025-12-06* +*Ultima actualizacion: 2025-12-12* diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/README.md b/projects/erp-suite/apps/verticales/mecanicas-diesel/README.md index 3013780..142fe7c 100644 --- a/projects/erp-suite/apps/verticales/mecanicas-diesel/README.md +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/README.md @@ -1,62 +1,85 @@ -# ERP Mecánicas Diesel - Vertical de Talleres +# ERP Mecanicas Diesel -## Descripción +## Descripcion -Vertical especializada del ERP Suite para laboratorios y talleres de mecánica diesel. **Extiende erp-core** con módulos específicos para diagnósticos, reparaciones, y gestión de refacciones. +Sistema ERP especializado para laboratorios y talleres de mecanica diesel. Incluye modulos especificos para diagnosticos, reparaciones, gestion de refacciones e inventario. -**Estado:** En planificación (0%) -**Versión:** 0.1.0 -**Base:** Extiende erp-core (60-70%) +**Estado:** Documentacion completa - Listo para desarrollo +**Version:** 1.0.0 +**Tipo:** Proyecto independiente ## Estructura del Proyecto ``` mecanicas-diesel/ -├── backend/ # Extensiones backend específicas -├── frontend/ # UI especializada -├── database/ # DDL y migrations específicos -├── docs/ # Documentación del proyecto -│ ├── 00-vision-general/ -│ ├── 01-fase-mvp/ -│ ├── 02-modelado/ -│ └── 90-transversal/ -└── orchestration/ # Sistema de agentes NEXUS - ├── 00-guidelines/ - │ └── CONTEXTO-PROYECTO.md - ├── trazas/ - ├── estados/ - └── PROXIMA-ACCION.md +├── backend/ # APIs y servicios (NestJS + TypeScript) +├── frontend/ # UI especializada (React + TypeScript) +├── database/ # DDL y scripts de base de datos +│ └── init/ # Scripts de inicializacion +├── docs/ # Documentacion del proyecto +│ ├── 00-vision-general/ # Vision y objetivos +│ ├── 02-definicion-modulos/ # Modulos y user stories +│ ├── 03-modelo-datos/ # Documentacion de schemas +│ ├── 08-epicas/ # Epicas del proyecto +│ └── 90-transversal/ # Documentacion transversal +└── orchestration/ # Sistema de agentes + ├── 00-guidelines/ # Guias del proyecto + ├── inventarios/ # Inventarios de componentes + └── PROXIMA-ACCION.md # Siguiente paso a ejecutar ``` -## Módulos Específicos Planificados +## Modulos del MVP -| Módulo | Descripción | Prioridad | -|--------|-------------|-----------| -| Diagnósticos | Pruebas y diagnósticos de equipos | Alta | -| Órdenes de Reparación | Gestión de servicios | Alta | -| Inventario Refacciones | Stock de partes y refacciones | Alta | -| Vehículos en Servicio | Control de unidades | Media | -| Cotizaciones | Presupuestos de reparación | Media | -| Historial de Servicios | Trazabilidad por vehículo | Media | +| Modulo | Codigo | Descripcion | Estado | +|--------|--------|-------------|--------| +| Fundamentos | MMD-001 | Configuracion, usuarios, tenants | Documentado | +| Ordenes de Servicio | MMD-002 | Gestion de servicios y trabajos | Documentado | +| Diagnosticos | MMD-003 | Pruebas y diagnosticos diesel | Documentado | +| Inventario | MMD-004 | Stock de refacciones | Documentado | +| Vehiculos | MMD-005 | Registro y control de unidades | Documentado | +| Cotizaciones | MMD-006 | Presupuestos de reparacion | Documentado | -## Schemas Planificados +## Schemas de Base de Datos -| Schema | Descripción | -|--------|-------------| -| `service_management` | Órdenes, diagnósticos, reparaciones | -| `parts_management` | Refacciones, proveedores | -| `vehicle_management` | Vehículos, historial | +| Schema | Tablas | Descripcion | +|--------|--------|-------------| +| workshop_core | 9 | Configuracion, usuarios, clientes | +| service_management | 14+ | Ordenes, diagnosticos, cotizaciones | +| parts_management | 12+ | Inventario, refacciones, garantias | +| vehicle_management | 8 | Vehiculos, flotas, motores | +| notifications | 6 | Tracking, followers, actividades | +| analytics | 4 | Contabilidad analitica | +| purchasing | 5 | Compras y proveedores | -## Documentación +**Total:** 65+ tablas con RLS multi-tenant -- **Contexto:** `orchestration/00-guidelines/CONTEXTO-PROYECTO.md` -- **Próxima acción:** `orchestration/PROXIMA-ACCION.md` -- **Trazas de agentes:** `orchestration/trazas/` +## Stack Tecnologico -## Dependencias +- **Base de datos:** PostgreSQL 15+ con Row Level Security +- **Backend:** Node.js + NestJS + TypeScript +- **Frontend:** React + TypeScript +- **Autenticacion:** JWT + RBAC -- **Requiere:** erp-core completado -- **Referencia:** Patrones de construcción aplicables +## Documentacion + +- **Estado del proyecto:** `PROJECT-STATUS.md` +- **Vision:** `docs/00-vision-general/VISION.md` +- **Epicas:** `docs/08-epicas/` +- **User Stories:** `docs/02-definicion-modulos/*/historias-usuario/` +- **Modelo de datos:** `docs/03-modelo-datos/` + +## Inicio Rapido + +```bash +# Crear base de datos +createdb mecanicas_diesel_db + +# Ejecutar scripts DDL en orden +psql -d mecanicas_diesel_db -f database/init/00-extensions.sql +psql -d mecanicas_diesel_db -f database/init/01-create-schemas.sql +psql -d mecanicas_diesel_db -f database/init/02-rls-functions.sql +# ... continuar con los demas scripts +``` --- -*Proyecto parte de ERP Suite - Fábrica de Software con Agentes IA* +*Proyecto parte de ERP Suite - Fabrica de Software con Agentes IA* diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/01-create-schemas.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/01-create-schemas.sql index 489e29e..537e79f 100644 --- a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/01-create-schemas.sql +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/01-create-schemas.sql @@ -1,10 +1,9 @@ -- =========================================== --- MECANICAS DIESEL - Creación de Schemas +-- MECANICAS DIESEL - Creacion de Schemas -- =========================================== --- Ejecutar después de extensiones +-- Ejecutar despues de extensiones -- Schemas propios de mecanicas-diesel --- NOTA: Los schemas auth, core, inventory se heredan de erp-core CREATE SCHEMA IF NOT EXISTS service_management; COMMENT ON SCHEMA service_management IS 'Órdenes de servicio, diagnósticos, cotizaciones'; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/03-service-management-tables.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/03-service-management-tables.sql index e81561c..a5a4887 100644 --- a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/03-service-management-tables.sql +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/03-service-management-tables.sql @@ -1,17 +1,16 @@ -- =========================================== -- MECANICAS DIESEL - Schema service_management -- =========================================== --- Órdenes de servicio, diagnósticos, cotizaciones --- NOTA: Usa auth.users y auth.tenants de erp-core +-- Ordenes de servicio, diagnosticos, cotizaciones SET search_path TO service_management, public; -- ------------------------------------------- --- SERVICE_ORDERS - Órdenes de servicio +-- SERVICE_ORDERS - Ordenes de servicio -- ------------------------------------------- CREATE TABLE service_management.service_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL, -- Referencia a auth.tenants de erp-core + tenant_id UUID NOT NULL, -- Identificador del taller (multi-tenant) -- Identificación order_number VARCHAR(20) NOT NULL, diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/04-parts-management-tables.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/04-parts-management-tables.sql index c37a39d..d327ee1 100644 --- a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/04-parts-management-tables.sql +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/04-parts-management-tables.sql @@ -1,8 +1,7 @@ -- =========================================== -- MECANICAS DIESEL - Schema parts_management -- =========================================== --- Inventario de refacciones específico del taller --- NOTA: Extiende inventory.* de erp-core para campos específicos +-- Inventario de refacciones especifico del taller SET search_path TO parts_management, public; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/07-notifications-schema.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/07-notifications-schema.sql new file mode 100644 index 0000000..cf39943 --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/07-notifications-schema.sql @@ -0,0 +1,459 @@ +-- =========================================== +-- MECANICAS DIESEL - Schema de Notificaciones +-- =========================================== +-- Sistema de tracking, mensajes, followers y actividades +-- Permite historial de cambios y notificaciones automaticas + +-- ============================================ +-- SCHEMA: notifications +-- ============================================ +CREATE SCHEMA IF NOT EXISTS notifications; +COMMENT ON SCHEMA notifications IS 'Sistema de tracking, mensajes, followers y actividades (patron mail.thread)'; + +-- Grants +GRANT USAGE ON SCHEMA notifications TO mecanicas_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA notifications TO mecanicas_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA notifications GRANT ALL ON TABLES TO mecanicas_user; + +-- ============================================ +-- GAP-01: Sistema de Tracking de Cambios +-- ============================================ + +-- Subtipos de mensaje (clasificacion) +CREATE TABLE notifications.message_subtypes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + res_model VARCHAR(100), -- NULL = aplica a todos los modelos + is_internal BOOLEAN NOT NULL DEFAULT false, + is_default BOOLEAN NOT NULL DEFAULT false, + sequence INTEGER NOT NULL DEFAULT 10, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE notifications.message_subtypes IS 'Clasificacion de tipos de mensaje (creacion, edicion, nota, etc.)'; + +-- Seed de subtipos predeterminados +INSERT INTO notifications.message_subtypes (code, name, description, is_internal, is_default, sequence) VALUES +('mt_note', 'Nota', 'Nota interna', true, true, 1), +('mt_comment', 'Comentario', 'Comentario publico', false, true, 2), +('mt_tracking', 'Cambio de valor', 'Cambio en campo trackeado', true, false, 10), +('mt_creation', 'Creacion', 'Documento creado', false, false, 5), +('mt_status_change', 'Cambio de estado', 'Estado del documento modificado', false, false, 6), +('mt_assignment', 'Asignacion', 'Documento asignado a usuario', false, false, 7); + +-- Mensajes (chatter/historial) +CREATE TABLE notifications.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Referencia al documento + res_model VARCHAR(100) NOT NULL, -- ej: 'service_management.service_orders' + res_id UUID NOT NULL, -- ID del documento + + -- Tipo y subtipo + message_type VARCHAR(20) NOT NULL DEFAULT 'notification', + subtype_id UUID REFERENCES notifications.message_subtypes(id), + + -- Autor + author_id UUID, -- Usuario que escribio el mensaje + author_name VARCHAR(256), -- Nombre del autor (desnormalizado) + + -- Contenido + subject VARCHAR(500), + body TEXT, + + -- Tracking de cambios (JSON array) + -- Formato: [{"field": "status", "old_value": "draft", "new_value": "confirmed", "field_label": "Estado"}] + tracking_values JSONB DEFAULT '[]'::jsonb, + + -- Metadatos + is_internal BOOLEAN NOT NULL DEFAULT false, + parent_id UUID REFERENCES notifications.messages(id), + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_message_type CHECK (message_type IN ('comment', 'notification', 'note', 'email', 'system')) +); + +COMMENT ON TABLE notifications.messages IS 'Historial de mensajes y cambios en documentos (chatter)'; +COMMENT ON COLUMN notifications.messages.res_model IS 'Nombre completo del modelo (schema.table)'; +COMMENT ON COLUMN notifications.messages.res_id IS 'ID del registro referenciado'; +COMMENT ON COLUMN notifications.messages.tracking_values IS 'Array JSON con cambios de campos trackeados'; + +-- Indices para messages +CREATE INDEX idx_messages_resource ON notifications.messages(res_model, res_id); +CREATE INDEX idx_messages_tenant ON notifications.messages(tenant_id); +CREATE INDEX idx_messages_created ON notifications.messages(created_at DESC); +CREATE INDEX idx_messages_author ON notifications.messages(author_id); +CREATE INDEX idx_messages_parent ON notifications.messages(parent_id) WHERE parent_id IS NOT NULL; + +-- RLS para messages +SELECT create_tenant_rls_policies('notifications', 'messages'); + +-- ============================================ +-- GAP-02: Sistema de Followers/Suscriptores +-- ============================================ + +-- Seguidores de documentos +CREATE TABLE notifications.followers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Referencia al documento + res_model VARCHAR(100) NOT NULL, + res_id UUID NOT NULL, + + -- Seguidor (puede ser usuario o partner/cliente) + partner_id UUID NOT NULL, -- ID del contacto/usuario + partner_type VARCHAR(20) NOT NULL DEFAULT 'user', + + -- Metadatos + reason VARCHAR(100), -- Por que sigue (manual, asignacion, creador, etc.) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_follower UNIQUE(tenant_id, res_model, res_id, partner_id), + CONSTRAINT chk_partner_type CHECK (partner_type IN ('user', 'customer', 'supplier')) +); + +COMMENT ON TABLE notifications.followers IS 'Suscriptores a documentos para notificaciones automaticas'; + +-- Subtipos a los que esta suscrito cada follower +CREATE TABLE notifications.follower_subtypes ( + follower_id UUID NOT NULL REFERENCES notifications.followers(id) ON DELETE CASCADE, + subtype_id UUID NOT NULL REFERENCES notifications.message_subtypes(id) ON DELETE CASCADE, + PRIMARY KEY (follower_id, subtype_id) +); + +COMMENT ON TABLE notifications.follower_subtypes IS 'Tipos de mensaje a los que esta suscrito cada follower'; + +-- Indices para followers +CREATE INDEX idx_followers_resource ON notifications.followers(res_model, res_id); +CREATE INDEX idx_followers_partner ON notifications.followers(partner_id); +CREATE INDEX idx_followers_tenant ON notifications.followers(tenant_id); + +-- RLS para followers +SELECT create_tenant_rls_policies('notifications', 'followers'); + +-- ============================================ +-- GAP-03: Actividades Programadas +-- ============================================ + +-- Tipos de actividad +CREATE TABLE notifications.activity_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Configuracion + icon VARCHAR(50) DEFAULT 'fa-tasks', + color VARCHAR(20) DEFAULT 'primary', + default_days INTEGER DEFAULT 0, -- Dias por defecto para deadline + + -- Restriccion por modelo (NULL = todos) + res_model VARCHAR(100), + + -- Metadatos + sequence INTEGER NOT NULL DEFAULT 10, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE notifications.activity_types IS 'Tipos de actividad disponibles (llamar, reunion, tarea, etc.)'; + +-- Seed de tipos predeterminados para taller mecanico +INSERT INTO notifications.activity_types (code, name, description, icon, color, default_days, sequence) VALUES +('call', 'Llamar cliente', 'Llamada telefonica al cliente', 'fa-phone', 'info', 0, 1), +('meeting', 'Cita de entrega', 'Cita para entregar vehiculo', 'fa-calendar-check', 'success', 0, 2), +('todo', 'Tarea pendiente', 'Tarea generica por completar', 'fa-tasks', 'warning', 1, 3), +('reminder', 'Recordatorio mantenimiento', 'Recordar cliente sobre proximo mantenimiento', 'fa-bell', 'secondary', 30, 4), +('followup', 'Seguimiento cotizacion', 'Dar seguimiento a cotizacion enviada', 'fa-envelope', 'primary', 3, 5), +('approval', 'Pendiente aprobacion', 'Esperar aprobacion de cliente o supervisor', 'fa-check-circle', 'danger', 1, 6), +('parts_arrival', 'Llegada de refacciones', 'Refacciones pendientes de llegar', 'fa-truck', 'info', 2, 7), +('quality_check', 'Revision de calidad', 'Inspeccion de trabajo terminado', 'fa-search', 'warning', 0, 8); + +-- Actividades programadas +CREATE TABLE notifications.activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Referencia al documento + res_model VARCHAR(100) NOT NULL, + res_id UUID NOT NULL, + + -- Tipo y asignacion + activity_type_id UUID NOT NULL REFERENCES notifications.activity_types(id), + user_id UUID NOT NULL, -- Usuario asignado + + -- Programacion + date_deadline DATE NOT NULL, + + -- Contenido + summary VARCHAR(500), + note TEXT, + + -- Estado + state VARCHAR(20) NOT NULL DEFAULT 'planned', + date_done TIMESTAMPTZ, + feedback TEXT, -- Comentario al completar + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + + CONSTRAINT chk_activity_state CHECK (state IN ('planned', 'today', 'overdue', 'done', 'canceled')) +); + +COMMENT ON TABLE notifications.activities IS 'Actividades y recordatorios programados asociados a documentos'; +COMMENT ON COLUMN notifications.activities.state IS 'Estado: planned (futuro), today (hoy), overdue (vencida), done (completada), canceled'; + +-- Indices para activities +CREATE INDEX idx_activities_resource ON notifications.activities(res_model, res_id); +CREATE INDEX idx_activities_user ON notifications.activities(user_id); +CREATE INDEX idx_activities_deadline ON notifications.activities(date_deadline); +CREATE INDEX idx_activities_tenant ON notifications.activities(tenant_id); +CREATE INDEX idx_activities_pending ON notifications.activities(user_id, date_deadline) + WHERE state NOT IN ('done', 'canceled'); + +-- RLS para activities +SELECT create_tenant_rls_policies('notifications', 'activities'); + +-- ============================================ +-- FUNCIONES AUXILIARES +-- ============================================ + +-- Funcion para actualizar estado de actividades (planned -> today -> overdue) +CREATE OR REPLACE FUNCTION notifications.update_activity_states() +RETURNS INTEGER AS $$ +DECLARE + v_updated INTEGER; +BEGIN + -- Actualizar a 'today' las que vencen hoy + UPDATE notifications.activities + SET state = 'today' + WHERE state = 'planned' + AND date_deadline = CURRENT_DATE; + + GET DIAGNOSTICS v_updated = ROW_COUNT; + + -- Actualizar a 'overdue' las vencidas + UPDATE notifications.activities + SET state = 'overdue' + WHERE state IN ('planned', 'today') + AND date_deadline < CURRENT_DATE; + + RETURN v_updated; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION notifications.update_activity_states() IS 'Actualiza estados de actividades segun fecha (ejecutar diariamente)'; + +-- Funcion para agregar follower automaticamente +CREATE OR REPLACE FUNCTION notifications.add_follower( + p_tenant_id UUID, + p_res_model VARCHAR(100), + p_res_id UUID, + p_partner_id UUID, + p_partner_type VARCHAR(20) DEFAULT 'user', + p_reason VARCHAR(100) DEFAULT 'manual' +) +RETURNS UUID AS $$ +DECLARE + v_follower_id UUID; +BEGIN + INSERT INTO notifications.followers (tenant_id, res_model, res_id, partner_id, partner_type, reason) + VALUES (p_tenant_id, p_res_model, p_res_id, p_partner_id, p_partner_type, p_reason) + ON CONFLICT (tenant_id, res_model, res_id, partner_id) DO NOTHING + RETURNING id INTO v_follower_id; + + -- Si ya existia, obtener el ID + IF v_follower_id IS NULL THEN + SELECT id INTO v_follower_id + FROM notifications.followers + WHERE tenant_id = p_tenant_id + AND res_model = p_res_model + AND res_id = p_res_id + AND partner_id = p_partner_id; + END IF; + + RETURN v_follower_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION notifications.add_follower IS 'Agrega un follower a un documento (idempotente)'; + +-- Funcion para registrar mensaje de tracking +CREATE OR REPLACE FUNCTION notifications.log_tracking_message( + p_tenant_id UUID, + p_res_model VARCHAR(100), + p_res_id UUID, + p_author_id UUID, + p_tracking_values JSONB, + p_body TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_message_id UUID; + v_subtype_id UUID; +BEGIN + -- Obtener subtipo de tracking + SELECT id INTO v_subtype_id + FROM notifications.message_subtypes + WHERE code = 'mt_tracking'; + + INSERT INTO notifications.messages ( + tenant_id, res_model, res_id, message_type, + subtype_id, author_id, body, tracking_values, is_internal + ) + VALUES ( + p_tenant_id, p_res_model, p_res_id, 'notification', + v_subtype_id, p_author_id, p_body, p_tracking_values, true + ) + RETURNING id INTO v_message_id; + + RETURN v_message_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION notifications.log_tracking_message IS 'Registra un mensaje de tracking de cambios'; + +-- Funcion para crear actividad +CREATE OR REPLACE FUNCTION notifications.create_activity( + p_tenant_id UUID, + p_res_model VARCHAR(100), + p_res_id UUID, + p_activity_type_code VARCHAR(50), + p_user_id UUID, + p_date_deadline DATE DEFAULT NULL, + p_summary VARCHAR(500) DEFAULT NULL, + p_note TEXT DEFAULT NULL, + p_created_by UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_activity_id UUID; + v_activity_type_id UUID; + v_default_days INTEGER; +BEGIN + -- Obtener tipo de actividad + SELECT id, default_days INTO v_activity_type_id, v_default_days + FROM notifications.activity_types + WHERE code = p_activity_type_code AND is_active = true; + + IF v_activity_type_id IS NULL THEN + RAISE EXCEPTION 'Activity type % not found', p_activity_type_code; + END IF; + + -- Calcular deadline si no se proporciono + IF p_date_deadline IS NULL THEN + p_date_deadline := CURRENT_DATE + v_default_days; + END IF; + + -- Determinar estado inicial + INSERT INTO notifications.activities ( + tenant_id, res_model, res_id, activity_type_id, user_id, + date_deadline, summary, note, state, created_by + ) + VALUES ( + p_tenant_id, p_res_model, p_res_id, v_activity_type_id, p_user_id, + p_date_deadline, p_summary, p_note, + CASE + WHEN p_date_deadline < CURRENT_DATE THEN 'overdue' + WHEN p_date_deadline = CURRENT_DATE THEN 'today' + ELSE 'planned' + END, + COALESCE(p_created_by, get_current_user_id()) + ) + RETURNING id INTO v_activity_id; + + RETURN v_activity_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION notifications.create_activity IS 'Crea una nueva actividad programada'; + +-- Funcion para completar actividad +CREATE OR REPLACE FUNCTION notifications.complete_activity( + p_activity_id UUID, + p_feedback TEXT DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +BEGIN + UPDATE notifications.activities + SET state = 'done', + date_done = NOW(), + feedback = p_feedback + WHERE id = p_activity_id + AND state NOT IN ('done', 'canceled'); + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION notifications.complete_activity IS 'Marca una actividad como completada'; + +-- ============================================ +-- VISTAS UTILES +-- ============================================ + +-- Vista de actividades pendientes por usuario +CREATE VIEW notifications.v_pending_activities AS +SELECT + a.id, + a.tenant_id, + a.res_model, + a.res_id, + at.code as activity_type_code, + at.name as activity_type_name, + at.icon, + at.color, + a.user_id, + a.date_deadline, + a.summary, + a.note, + a.state, + CASE + WHEN a.date_deadline < CURRENT_DATE THEN a.date_deadline - CURRENT_DATE + ELSE 0 + END as days_overdue, + a.created_at +FROM notifications.activities a +JOIN notifications.activity_types at ON at.id = a.activity_type_id +WHERE a.state NOT IN ('done', 'canceled') +ORDER BY a.date_deadline ASC; + +COMMENT ON VIEW notifications.v_pending_activities IS 'Actividades pendientes con informacion de tipo'; + +-- Vista de historial de mensajes por documento +CREATE VIEW notifications.v_message_history AS +SELECT + m.id, + m.tenant_id, + m.res_model, + m.res_id, + m.message_type, + ms.code as subtype_code, + ms.name as subtype_name, + m.author_id, + m.author_name, + m.subject, + m.body, + m.tracking_values, + m.is_internal, + m.parent_id, + m.created_at +FROM notifications.messages m +LEFT JOIN notifications.message_subtypes ms ON ms.id = m.subtype_id +ORDER BY m.created_at DESC; + +COMMENT ON VIEW notifications.v_message_history IS 'Historial de mensajes formateado con subtipos'; + +-- ============================================ +-- GRANTS ADICIONALES +-- ============================================ +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA notifications TO mecanicas_user; +GRANT SELECT ON ALL TABLES IN SCHEMA notifications TO mecanicas_user; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/08-analytics-schema.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/08-analytics-schema.sql new file mode 100644 index 0000000..64bc1ab --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/08-analytics-schema.sql @@ -0,0 +1,387 @@ +-- =========================================== +-- MECANICAS DIESEL - Schema de Contabilidad Analitica +-- =========================================== +-- Resuelve: GAP-05 +-- Permite P&L por orden de servicio +-- Referencia: SPEC-CONTABILIDAD-ANALITICA-MULTIDIMENSIONAL.md + +-- ============================================ +-- SCHEMA: analytics +-- ============================================ +CREATE SCHEMA IF NOT EXISTS analytics; +COMMENT ON SCHEMA analytics IS 'Contabilidad analitica simplificada - costos e ingresos por orden'; + +-- Grants +GRANT USAGE ON SCHEMA analytics TO mecanicas_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA analytics TO mecanicas_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA analytics GRANT ALL ON TABLES TO mecanicas_user; + +-- ============================================ +-- CUENTAS ANALITICAS +-- ============================================ + +-- Tipos de cuenta analitica +CREATE TABLE analytics.account_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(20) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + sequence INTEGER NOT NULL DEFAULT 10, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE analytics.account_types IS 'Clasificacion de cuentas analiticas'; + +-- Seed de tipos predeterminados para taller +INSERT INTO analytics.account_types (code, name, description, sequence) VALUES +('service_order', 'Orden de Servicio', 'Cuenta por orden de servicio individual', 1), +('project', 'Proyecto', 'Agrupacion de multiples ordenes', 2), +('vehicle', 'Vehiculo', 'Costos historicos por vehiculo', 3), +('customer', 'Cliente', 'Rentabilidad por cliente', 4), +('department', 'Departamento', 'Costos por area del taller', 5); + +-- Cuentas analiticas +CREATE TABLE analytics.accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Identificacion + code VARCHAR(30) NOT NULL, + name VARCHAR(150) NOT NULL, + + -- Clasificacion + account_type_id UUID NOT NULL REFERENCES analytics.account_types(id), + + -- Referencia al documento origen (opcional) + res_model VARCHAR(100), -- ej: 'service_management.service_orders' + res_id UUID, -- ID del documento + + -- Jerarquia (para agrupaciones) + parent_id UUID REFERENCES analytics.accounts(id), + + -- Presupuesto (opcional) + budget_amount DECIMAL(20,6) DEFAULT 0, + + -- Metadatos + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + CONSTRAINT uq_analytics_account UNIQUE(tenant_id, code) +); + +COMMENT ON TABLE analytics.accounts IS 'Cuentas analiticas para tracking de costos/ingresos'; +COMMENT ON COLUMN analytics.accounts.res_model IS 'Modelo relacionado (ej: service_orders)'; +COMMENT ON COLUMN analytics.accounts.res_id IS 'ID del documento relacionado'; + +-- Indices para accounts +CREATE INDEX idx_analytics_accounts_tenant ON analytics.accounts(tenant_id); +CREATE INDEX idx_analytics_accounts_type ON analytics.accounts(account_type_id); +CREATE INDEX idx_analytics_accounts_parent ON analytics.accounts(parent_id) WHERE parent_id IS NOT NULL; +CREATE INDEX idx_analytics_accounts_resource ON analytics.accounts(res_model, res_id) WHERE res_model IS NOT NULL; + +-- RLS para accounts +SELECT create_tenant_rls_policies('analytics', 'accounts'); + +-- Trigger para updated_at +CREATE TRIGGER set_updated_at_analytics_accounts + BEFORE UPDATE ON analytics.accounts + FOR EACH ROW + EXECUTE FUNCTION trigger_set_updated_at(); + +-- ============================================ +-- LINEAS ANALITICAS (Movimientos) +-- ============================================ + +-- Categorias de linea (costo vs ingreso) +CREATE TYPE analytics.line_category AS ENUM ('cost', 'revenue', 'adjustment'); + +-- Lineas analiticas +CREATE TABLE analytics.lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Cuenta analitica + account_id UUID NOT NULL REFERENCES analytics.accounts(id), + + -- Fecha y descripcion + date DATE NOT NULL, + name VARCHAR(256) NOT NULL, + ref VARCHAR(100), -- Referencia externa (factura, orden, etc.) + + -- Importes + amount DECIMAL(20,6) NOT NULL, -- Positivo = ingreso, negativo = costo + category analytics.line_category NOT NULL, + unit_amount DECIMAL(20,6), -- Cantidad de unidades (horas, piezas) + unit_cost DECIMAL(20,6), -- Costo unitario + + -- Origen del movimiento + source_model VARCHAR(100), -- Modelo que genero la linea + source_id UUID, -- ID del registro origen + + -- Detalle adicional + product_id UUID, -- Producto/refaccion si aplica + employee_id UUID, -- Empleado si es mano de obra + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID +); + +COMMENT ON TABLE analytics.lines IS 'Movimientos de costos/ingresos en cuentas analiticas'; +COMMENT ON COLUMN analytics.lines.amount IS 'Monto: positivo=ingreso, negativo=costo'; +COMMENT ON COLUMN analytics.lines.unit_amount IS 'Cantidad (horas de mano de obra, unidades de refaccion)'; + +-- Indices para lines +CREATE INDEX idx_analytics_lines_tenant ON analytics.lines(tenant_id); +CREATE INDEX idx_analytics_lines_account ON analytics.lines(account_id); +CREATE INDEX idx_analytics_lines_date ON analytics.lines(date); +CREATE INDEX idx_analytics_lines_category ON analytics.lines(category); +CREATE INDEX idx_analytics_lines_source ON analytics.lines(source_model, source_id) WHERE source_model IS NOT NULL; +CREATE INDEX idx_analytics_lines_product ON analytics.lines(product_id) WHERE product_id IS NOT NULL; + +-- RLS para lines +SELECT create_tenant_rls_policies('analytics', 'lines'); + +-- ============================================ +-- FUNCIONES PARA GESTION ANALITICA +-- ============================================ + +-- Funcion para crear cuenta analitica automatica para orden de servicio +CREATE OR REPLACE FUNCTION analytics.create_service_order_account( + p_tenant_id UUID, + p_service_order_id UUID, + p_order_number VARCHAR(50), + p_customer_name VARCHAR(256) DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_account_id UUID; + v_type_id UUID; + v_code VARCHAR(30); + v_name VARCHAR(150); +BEGIN + -- Obtener tipo 'service_order' + SELECT id INTO v_type_id + FROM analytics.account_types + WHERE code = 'service_order'; + + -- Generar codigo y nombre + v_code := 'OS-' || p_order_number; + v_name := 'Orden ' || p_order_number; + IF p_customer_name IS NOT NULL THEN + v_name := v_name || ' - ' || LEFT(p_customer_name, 50); + END IF; + + -- Insertar cuenta + INSERT INTO analytics.accounts ( + tenant_id, code, name, account_type_id, + res_model, res_id + ) + VALUES ( + p_tenant_id, v_code, v_name, v_type_id, + 'service_management.service_orders', p_service_order_id + ) + ON CONFLICT (tenant_id, code) DO UPDATE SET + name = EXCLUDED.name, + updated_at = NOW() + RETURNING id INTO v_account_id; + + RETURN v_account_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION analytics.create_service_order_account IS 'Crea cuenta analitica automatica para orden de servicio'; + +-- Funcion para registrar costo de refaccion +CREATE OR REPLACE FUNCTION analytics.log_parts_cost( + p_tenant_id UUID, + p_account_id UUID, + p_part_id UUID, + p_part_name VARCHAR(256), + p_quantity DECIMAL(20,6), + p_unit_cost DECIMAL(20,6), + p_source_model VARCHAR(100) DEFAULT NULL, + p_source_id UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_line_id UUID; + v_total_cost DECIMAL(20,6); +BEGIN + v_total_cost := p_quantity * p_unit_cost * -1; -- Negativo porque es costo + + INSERT INTO analytics.lines ( + tenant_id, account_id, date, name, ref, + amount, category, unit_amount, unit_cost, + source_model, source_id, product_id, created_by + ) + VALUES ( + p_tenant_id, p_account_id, CURRENT_DATE, + 'Refaccion: ' || p_part_name, NULL, + v_total_cost, 'cost', p_quantity, p_unit_cost, + p_source_model, p_source_id, p_part_id, get_current_user_id() + ) + RETURNING id INTO v_line_id; + + RETURN v_line_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION analytics.log_parts_cost IS 'Registra costo de refacciones usadas'; + +-- Funcion para registrar costo de mano de obra +CREATE OR REPLACE FUNCTION analytics.log_labor_cost( + p_tenant_id UUID, + p_account_id UUID, + p_employee_id UUID, + p_employee_name VARCHAR(256), + p_hours DECIMAL(20,6), + p_hourly_rate DECIMAL(20,6), + p_description VARCHAR(256) DEFAULT NULL, + p_source_model VARCHAR(100) DEFAULT NULL, + p_source_id UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_line_id UUID; + v_total_cost DECIMAL(20,6); +BEGIN + v_total_cost := p_hours * p_hourly_rate * -1; -- Negativo porque es costo + + INSERT INTO analytics.lines ( + tenant_id, account_id, date, name, ref, + amount, category, unit_amount, unit_cost, + source_model, source_id, employee_id, created_by + ) + VALUES ( + p_tenant_id, p_account_id, CURRENT_DATE, + COALESCE(p_description, 'Mano de obra: ' || p_employee_name), NULL, + v_total_cost, 'cost', p_hours, p_hourly_rate, + p_source_model, p_source_id, p_employee_id, get_current_user_id() + ) + RETURNING id INTO v_line_id; + + RETURN v_line_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION analytics.log_labor_cost IS 'Registra costo de mano de obra'; + +-- Funcion para registrar ingreso (facturacion) +CREATE OR REPLACE FUNCTION analytics.log_revenue( + p_tenant_id UUID, + p_account_id UUID, + p_amount DECIMAL(20,6), + p_description VARCHAR(256), + p_ref VARCHAR(100) DEFAULT NULL, + p_source_model VARCHAR(100) DEFAULT NULL, + p_source_id UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_line_id UUID; +BEGIN + INSERT INTO analytics.lines ( + tenant_id, account_id, date, name, ref, + amount, category, unit_amount, unit_cost, + source_model, source_id, created_by + ) + VALUES ( + p_tenant_id, p_account_id, CURRENT_DATE, + p_description, p_ref, + ABS(p_amount), 'revenue', NULL, NULL, + p_source_model, p_source_id, get_current_user_id() + ) + RETURNING id INTO v_line_id; + + RETURN v_line_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION analytics.log_revenue IS 'Registra ingreso en cuenta analitica'; + +-- ============================================ +-- VISTAS PARA REPORTES +-- ============================================ + +-- Vista de P&L por cuenta analitica +CREATE VIEW analytics.v_account_pnl AS +SELECT + a.id as account_id, + a.tenant_id, + a.code, + a.name, + at.code as account_type, + a.res_model, + a.res_id, + a.budget_amount, + COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 0) as total_revenue, + COALESCE(SUM(CASE WHEN l.category = 'cost' THEN ABS(l.amount) ELSE 0 END), 0) as total_cost, + COALESCE(SUM(l.amount), 0) as net_profit, + CASE + WHEN COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 0) = 0 THEN 0 + ELSE ROUND( + (COALESCE(SUM(l.amount), 0) / + COALESCE(SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END), 1)) * 100, + 2 + ) + END as margin_percent, + COUNT(DISTINCT l.id) as line_count +FROM analytics.accounts a +JOIN analytics.account_types at ON at.id = a.account_type_id +LEFT JOIN analytics.lines l ON l.account_id = a.id +WHERE a.is_active = true +GROUP BY a.id, a.tenant_id, a.code, a.name, at.code, a.res_model, a.res_id, a.budget_amount; + +COMMENT ON VIEW analytics.v_account_pnl IS 'Estado de resultados por cuenta analitica'; + +-- Vista de detalle de costos por orden +CREATE VIEW analytics.v_service_order_costs AS +SELECT + a.res_id as service_order_id, + a.tenant_id, + a.code as account_code, + l.date, + l.name, + l.category, + l.amount, + l.unit_amount, + l.unit_cost, + l.product_id, + l.employee_id, + l.source_model, + l.source_id, + l.created_at +FROM analytics.accounts a +JOIN analytics.account_types at ON at.id = a.account_type_id AND at.code = 'service_order' +JOIN analytics.lines l ON l.account_id = a.id +WHERE a.res_model = 'service_management.service_orders' +ORDER BY l.created_at DESC; + +COMMENT ON VIEW analytics.v_service_order_costs IS 'Detalle de costos e ingresos por orden de servicio'; + +-- Vista resumen mensual +CREATE VIEW analytics.v_monthly_summary AS +SELECT + a.tenant_id, + DATE_TRUNC('month', l.date) as month, + at.code as account_type, + COUNT(DISTINCT a.id) as account_count, + SUM(CASE WHEN l.category = 'revenue' THEN l.amount ELSE 0 END) as total_revenue, + SUM(CASE WHEN l.category = 'cost' THEN ABS(l.amount) ELSE 0 END) as total_cost, + SUM(l.amount) as net_profit +FROM analytics.lines l +JOIN analytics.accounts a ON a.id = l.account_id +JOIN analytics.account_types at ON at.id = a.account_type_id +GROUP BY a.tenant_id, DATE_TRUNC('month', l.date), at.code +ORDER BY month DESC, account_type; + +COMMENT ON VIEW analytics.v_monthly_summary IS 'Resumen de rentabilidad mensual por tipo de cuenta'; + +-- ============================================ +-- GRANTS ADICIONALES +-- ============================================ +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA analytics TO mecanicas_user; +GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO mecanicas_user; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/09-purchasing-schema.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/09-purchasing-schema.sql new file mode 100644 index 0000000..c53d602 --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/09-purchasing-schema.sql @@ -0,0 +1,531 @@ +-- =========================================== +-- MECANICAS DIESEL - Schema de Compras +-- =========================================== +-- Sistema de ordenes de compra, proveedores y recepciones +-- Gestion completa del ciclo de compras del taller + +-- ============================================ +-- SCHEMA: purchasing +-- ============================================ +CREATE SCHEMA IF NOT EXISTS purchasing; +COMMENT ON SCHEMA purchasing IS 'Gestion de compras: ordenes de compra, recepciones, proveedores'; + +-- Grants +GRANT USAGE ON SCHEMA purchasing TO mecanicas_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA purchasing GRANT ALL ON TABLES TO mecanicas_user; + +-- ============================================ +-- PROVEEDORES (complementa partners existentes) +-- ============================================ + +-- Extension de datos de proveedor +CREATE TABLE purchasing.suppliers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Datos basicos + code VARCHAR(20) NOT NULL, + name VARCHAR(256) NOT NULL, + trade_name VARCHAR(256), -- Nombre comercial + rfc VARCHAR(13), + + -- Contacto + contact_name VARCHAR(256), + email VARCHAR(256), + phone VARCHAR(50), + mobile VARCHAR(50), + + -- Direccion + street VARCHAR(256), + city VARCHAR(100), + state VARCHAR(100), + zip_code VARCHAR(10), + country VARCHAR(100) DEFAULT 'Mexico', + + -- Datos comerciales + payment_term_days INTEGER DEFAULT 30, + credit_limit DECIMAL(20,6) DEFAULT 0, + currency_code VARCHAR(3) DEFAULT 'MXN', + + -- Clasificacion + category VARCHAR(50), -- refacciones, lubricantes, herramientas, etc. + is_preferred BOOLEAN NOT NULL DEFAULT false, + rating INTEGER CHECK (rating BETWEEN 1 AND 5), + + -- Notas + notes TEXT, + + -- Metadatos + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + created_by UUID, + + CONSTRAINT uq_supplier_code UNIQUE(tenant_id, code) +); + +COMMENT ON TABLE purchasing.suppliers IS 'Proveedores del taller'; + +-- Indices para suppliers +CREATE INDEX idx_suppliers_tenant ON purchasing.suppliers(tenant_id); +CREATE INDEX idx_suppliers_name ON purchasing.suppliers(name); +CREATE INDEX idx_suppliers_category ON purchasing.suppliers(category); +CREATE INDEX idx_suppliers_preferred ON purchasing.suppliers(is_preferred) WHERE is_preferred = true; + +-- RLS para suppliers +SELECT create_tenant_rls_policies('purchasing', 'suppliers'); + +-- Trigger para updated_at +CREATE TRIGGER set_updated_at_suppliers + BEFORE UPDATE ON purchasing.suppliers + FOR EACH ROW + EXECUTE FUNCTION trigger_set_updated_at(); + +-- ============================================ +-- ORDENES DE COMPRA +-- ============================================ + +-- Estados de orden de compra +CREATE TYPE purchasing.po_status AS ENUM ( + 'draft', -- Borrador + 'sent', -- Enviada a proveedor + 'confirmed', -- Confirmada por proveedor + 'partial', -- Parcialmente recibida + 'received', -- Completamente recibida + 'cancelled' -- Cancelada +); + +-- Ordenes de compra +CREATE TABLE purchasing.purchase_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Identificacion + order_number VARCHAR(50) NOT NULL, + reference VARCHAR(100), -- Referencia del proveedor + + -- Proveedor + supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id), + + -- Estado + status purchasing.po_status NOT NULL DEFAULT 'draft', + + -- Fechas + order_date DATE NOT NULL DEFAULT CURRENT_DATE, + expected_date DATE, -- Fecha esperada de entrega + received_date DATE, -- Fecha de recepcion completa + + -- Importes + subtotal DECIMAL(20,6) NOT NULL DEFAULT 0, + discount_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + total DECIMAL(20,6) NOT NULL DEFAULT 0, + currency_code VARCHAR(3) DEFAULT 'MXN', + + -- Urgencia (para taller) + priority VARCHAR(20) DEFAULT 'normal', + service_order_id UUID, -- Orden de servicio relacionada (si aplica) + + -- Notas + notes TEXT, + internal_notes TEXT, + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + created_by UUID NOT NULL, + confirmed_by UUID, + confirmed_at TIMESTAMPTZ, + + CONSTRAINT uq_po_number UNIQUE(tenant_id, order_number), + CONSTRAINT chk_po_priority CHECK (priority IN ('low', 'normal', 'high', 'urgent')) +); + +COMMENT ON TABLE purchasing.purchase_orders IS 'Ordenes de compra a proveedores'; +COMMENT ON COLUMN purchasing.purchase_orders.service_order_id IS 'Orden de servicio que origino la compra (para urgencias)'; + +-- Indices para purchase_orders +CREATE INDEX idx_po_tenant ON purchasing.purchase_orders(tenant_id); +CREATE INDEX idx_po_supplier ON purchasing.purchase_orders(supplier_id); +CREATE INDEX idx_po_status ON purchasing.purchase_orders(status); +CREATE INDEX idx_po_date ON purchasing.purchase_orders(order_date DESC); +CREATE INDEX idx_po_expected ON purchasing.purchase_orders(expected_date) WHERE status NOT IN ('received', 'cancelled'); +CREATE INDEX idx_po_service_order ON purchasing.purchase_orders(service_order_id) WHERE service_order_id IS NOT NULL; + +-- RLS para purchase_orders +SELECT create_tenant_rls_policies('purchasing', 'purchase_orders'); + +-- Triggers +CREATE TRIGGER set_updated_at_purchase_orders + BEFORE UPDATE ON purchasing.purchase_orders + FOR EACH ROW + EXECUTE FUNCTION trigger_set_updated_at(); + +-- ============================================ +-- LINEAS DE ORDEN DE COMPRA +-- ============================================ + +CREATE TABLE purchasing.purchase_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id) ON DELETE CASCADE, + + -- Linea + line_number INTEGER NOT NULL DEFAULT 1, + + -- Producto + part_id UUID, -- Referencia a parts_management.parts + product_code VARCHAR(50), -- Codigo del producto (desnormalizado) + description VARCHAR(500) NOT NULL, + + -- Cantidades + quantity DECIMAL(20,6) NOT NULL, + unit_of_measure VARCHAR(20) DEFAULT 'PZA', + received_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, + + -- Precios + unit_price DECIMAL(20,6) NOT NULL, + discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0, + subtotal DECIMAL(20,6) NOT NULL, + tax_percent DECIMAL(5,2) NOT NULL DEFAULT 16.00, -- IVA Mexico + tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + total DECIMAL(20,6) NOT NULL, + + -- Fechas + expected_date DATE, + + -- Estado de linea + is_closed BOOLEAN NOT NULL DEFAULT false, + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE purchasing.purchase_order_lines IS 'Lineas de detalle de ordenes de compra'; + +-- Indices para lines +CREATE INDEX idx_pol_order ON purchasing.purchase_order_lines(purchase_order_id); +CREATE INDEX idx_pol_part ON purchasing.purchase_order_lines(part_id) WHERE part_id IS NOT NULL; +CREATE INDEX idx_pol_pending ON purchasing.purchase_order_lines(purchase_order_id) + WHERE received_quantity < quantity AND is_closed = false; + +-- ============================================ +-- RECEPCIONES DE COMPRA +-- ============================================ + +CREATE TABLE purchasing.purchase_receipts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Identificacion + receipt_number VARCHAR(50) NOT NULL, + purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id), + + -- Fecha y proveedor + receipt_date DATE NOT NULL DEFAULT CURRENT_DATE, + supplier_id UUID NOT NULL REFERENCES purchasing.suppliers(id), + + -- Documentos del proveedor + supplier_invoice VARCHAR(50), -- Numero de factura proveedor + supplier_delivery_note VARCHAR(50), -- Remision del proveedor + + -- Notas + notes TEXT, + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + + CONSTRAINT uq_receipt_number UNIQUE(tenant_id, receipt_number) +); + +COMMENT ON TABLE purchasing.purchase_receipts IS 'Recepciones de mercancia de ordenes de compra'; + +-- Indices para receipts +CREATE INDEX idx_pr_tenant ON purchasing.purchase_receipts(tenant_id); +CREATE INDEX idx_pr_order ON purchasing.purchase_receipts(purchase_order_id); +CREATE INDEX idx_pr_date ON purchasing.purchase_receipts(receipt_date DESC); + +-- RLS para receipts +SELECT create_tenant_rls_policies('purchasing', 'purchase_receipts'); + +-- Lineas de recepcion +CREATE TABLE purchasing.purchase_receipt_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + receipt_id UUID NOT NULL REFERENCES purchasing.purchase_receipts(id) ON DELETE CASCADE, + order_line_id UUID NOT NULL REFERENCES purchasing.purchase_order_lines(id), + + -- Cantidades + quantity_received DECIMAL(20,6) NOT NULL, + quantity_rejected DECIMAL(20,6) NOT NULL DEFAULT 0, + rejection_reason VARCHAR(256), + + -- Lote/Serie (si aplica) + lot_number VARCHAR(100), + serial_numbers TEXT[], + + -- Ubicacion destino + location_id UUID, -- Referencia a inventory location + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE purchasing.purchase_receipt_lines IS 'Detalle de productos recibidos'; + +CREATE INDEX idx_prl_receipt ON purchasing.purchase_receipt_lines(receipt_id); +CREATE INDEX idx_prl_order_line ON purchasing.purchase_receipt_lines(order_line_id); + +-- ============================================ +-- FUNCIONES AUXILIARES +-- ============================================ + +-- Funcion para generar numero de orden de compra +CREATE OR REPLACE FUNCTION purchasing.generate_po_number(p_tenant_id UUID) +RETURNS VARCHAR(50) AS $$ +DECLARE + v_year TEXT; + v_sequence INTEGER; + v_number VARCHAR(50); +BEGIN + v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); + + -- Obtener siguiente secuencia del año + SELECT COALESCE(MAX( + CAST(SUBSTRING(order_number FROM 'OC-' || v_year || '-(\d+)') AS INTEGER) + ), 0) + 1 + INTO v_sequence + FROM purchasing.purchase_orders + WHERE tenant_id = p_tenant_id + AND order_number LIKE 'OC-' || v_year || '-%'; + + v_number := 'OC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); + + RETURN v_number; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION purchasing.generate_po_number IS 'Genera numero secuencial de orden de compra (OC-YYYY-NNNNN)'; + +-- Funcion para generar numero de recepcion +CREATE OR REPLACE FUNCTION purchasing.generate_receipt_number(p_tenant_id UUID) +RETURNS VARCHAR(50) AS $$ +DECLARE + v_year TEXT; + v_sequence INTEGER; + v_number VARCHAR(50); +BEGIN + v_year := TO_CHAR(CURRENT_DATE, 'YYYY'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(receipt_number FROM 'REC-' || v_year || '-(\d+)') AS INTEGER) + ), 0) + 1 + INTO v_sequence + FROM purchasing.purchase_receipts + WHERE tenant_id = p_tenant_id + AND receipt_number LIKE 'REC-' || v_year || '-%'; + + v_number := 'REC-' || v_year || '-' || LPAD(v_sequence::TEXT, 5, '0'); + + RETURN v_number; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION purchasing.generate_receipt_number IS 'Genera numero secuencial de recepcion (REC-YYYY-NNNNN)'; + +-- Funcion para calcular totales de linea +CREATE OR REPLACE FUNCTION purchasing.calculate_line_totals() +RETURNS TRIGGER AS $$ +BEGIN + -- Calcular subtotal (con descuento) + NEW.subtotal := NEW.quantity * NEW.unit_price * (1 - NEW.discount_percent / 100); + + -- Calcular impuesto + NEW.tax_amount := NEW.subtotal * NEW.tax_percent / 100; + + -- Calcular total + NEW.total := NEW.subtotal + NEW.tax_amount; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER calculate_pol_totals + BEFORE INSERT OR UPDATE OF quantity, unit_price, discount_percent, tax_percent + ON purchasing.purchase_order_lines + FOR EACH ROW + EXECUTE FUNCTION purchasing.calculate_line_totals(); + +-- Funcion para actualizar totales de orden +CREATE OR REPLACE FUNCTION purchasing.update_order_totals() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE purchasing.purchase_orders po + SET + subtotal = COALESCE(( + SELECT SUM(subtotal) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id + ), 0), + tax_amount = COALESCE(( + SELECT SUM(tax_amount) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id + ), 0), + total = COALESCE(( + SELECT SUM(total) FROM purchasing.purchase_order_lines WHERE purchase_order_id = po.id + ), 0), + updated_at = NOW() + WHERE id = COALESCE(NEW.purchase_order_id, OLD.purchase_order_id); + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_po_totals + AFTER INSERT OR UPDATE OR DELETE + ON purchasing.purchase_order_lines + FOR EACH ROW + EXECUTE FUNCTION purchasing.update_order_totals(); + +-- Funcion para actualizar cantidades recibidas +CREATE OR REPLACE FUNCTION purchasing.update_received_quantities() +RETURNS TRIGGER AS $$ +DECLARE + v_order_id UUID; + v_total_lines INTEGER; + v_received_lines INTEGER; +BEGIN + -- Actualizar cantidad recibida en linea de orden + UPDATE purchasing.purchase_order_lines pol + SET received_quantity = COALESCE(( + SELECT SUM(prl.quantity_received) + FROM purchasing.purchase_receipt_lines prl + WHERE prl.order_line_id = pol.id + ), 0) + WHERE id = NEW.order_line_id; + + -- Obtener orden de compra + SELECT purchase_order_id INTO v_order_id + FROM purchasing.purchase_order_lines + WHERE id = NEW.order_line_id; + + -- Verificar si toda la orden fue recibida + SELECT + COUNT(*), + COUNT(*) FILTER (WHERE received_quantity >= quantity) + INTO v_total_lines, v_received_lines + FROM purchasing.purchase_order_lines + WHERE purchase_order_id = v_order_id; + + -- Actualizar estado de la orden + UPDATE purchasing.purchase_orders + SET status = CASE + WHEN v_received_lines = v_total_lines THEN 'received'::purchasing.po_status + WHEN v_received_lines > 0 THEN 'partial'::purchasing.po_status + ELSE status + END, + received_date = CASE + WHEN v_received_lines = v_total_lines THEN CURRENT_DATE + ELSE received_date + END, + updated_at = NOW() + WHERE id = v_order_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_po_received + AFTER INSERT + ON purchasing.purchase_receipt_lines + FOR EACH ROW + EXECUTE FUNCTION purchasing.update_received_quantities(); + +-- ============================================ +-- VISTAS UTILES +-- ============================================ + +-- Vista de ordenes de compra pendientes +CREATE VIEW purchasing.v_pending_orders AS +SELECT + po.id, + po.tenant_id, + po.order_number, + po.status, + po.order_date, + po.expected_date, + s.name as supplier_name, + s.contact_name, + s.phone as supplier_phone, + po.total, + po.priority, + po.service_order_id, + CASE + WHEN po.expected_date < CURRENT_DATE THEN 'overdue' + WHEN po.expected_date = CURRENT_DATE THEN 'today' + WHEN po.expected_date <= CURRENT_DATE + 3 THEN 'soon' + ELSE 'normal' + END as urgency, + COUNT(pol.id) as line_count, + SUM(CASE WHEN pol.received_quantity < pol.quantity THEN 1 ELSE 0 END) as pending_lines +FROM purchasing.purchase_orders po +JOIN purchasing.suppliers s ON s.id = po.supplier_id +LEFT JOIN purchasing.purchase_order_lines pol ON pol.purchase_order_id = po.id +WHERE po.status NOT IN ('received', 'cancelled') +GROUP BY po.id, po.tenant_id, po.order_number, po.status, po.order_date, + po.expected_date, s.name, s.contact_name, s.phone, po.total, + po.priority, po.service_order_id +ORDER BY po.expected_date ASC NULLS LAST, po.priority DESC; + +COMMENT ON VIEW purchasing.v_pending_orders IS 'Ordenes de compra pendientes de recibir'; + +-- Vista de productos pendientes de recibir +CREATE VIEW purchasing.v_pending_products AS +SELECT + po.tenant_id, + pol.part_id, + pol.product_code, + pol.description, + po.order_number, + po.supplier_id, + s.name as supplier_name, + pol.quantity, + pol.received_quantity, + pol.quantity - pol.received_quantity as pending_quantity, + pol.unit_price, + po.expected_date, + po.service_order_id +FROM purchasing.purchase_order_lines pol +JOIN purchasing.purchase_orders po ON po.id = pol.purchase_order_id +JOIN purchasing.suppliers s ON s.id = po.supplier_id +WHERE pol.received_quantity < pol.quantity + AND pol.is_closed = false + AND po.status NOT IN ('cancelled') +ORDER BY po.expected_date ASC NULLS LAST; + +COMMENT ON VIEW purchasing.v_pending_products IS 'Productos pendientes de recibir por orden'; + +-- Vista de historial de compras por proveedor +CREATE VIEW purchasing.v_supplier_history AS +SELECT + s.id as supplier_id, + s.tenant_id, + s.code as supplier_code, + s.name as supplier_name, + s.category, + s.rating, + COUNT(DISTINCT po.id) as total_orders, + COUNT(DISTINCT po.id) FILTER (WHERE po.status = 'received') as completed_orders, + SUM(po.total) FILTER (WHERE po.status = 'received') as total_purchased, + AVG(po.received_date - po.expected_date) FILTER (WHERE po.status = 'received' AND po.expected_date IS NOT NULL) as avg_delivery_days, + MAX(po.order_date) as last_order_date +FROM purchasing.suppliers s +LEFT JOIN purchasing.purchase_orders po ON po.supplier_id = s.id +WHERE s.is_active = true +GROUP BY s.id, s.tenant_id, s.code, s.name, s.category, s.rating +ORDER BY total_purchased DESC NULLS LAST; + +COMMENT ON VIEW purchasing.v_supplier_history IS 'Historial y estadisticas de compras por proveedor'; + +-- ============================================ +-- GRANTS ADICIONALES +-- ============================================ +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA purchasing TO mecanicas_user; +GRANT SELECT ON ALL TABLES IN SCHEMA purchasing TO mecanicas_user; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/10-warranty-claims.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/10-warranty-claims.sql new file mode 100644 index 0000000..89fe535 --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/10-warranty-claims.sql @@ -0,0 +1,469 @@ +-- =========================================== +-- MECANICAS DIESEL - Tracking de Garantias +-- =========================================== +-- Resuelve: GAP-10 +-- Sistema de seguimiento de garantias de refacciones +-- Permite reclamar garantias a proveedores + +-- ============================================ +-- EXTENSION DE PARTS PARA GARANTIAS +-- ============================================ + +-- Agregar campos de garantia a parts si no existen +DO $$ +BEGIN + -- warranty_months + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'parts_management' + AND table_name = 'parts' + AND column_name = 'warranty_months' + ) THEN + ALTER TABLE parts_management.parts + ADD COLUMN warranty_months INTEGER DEFAULT 0; + COMMENT ON COLUMN parts_management.parts.warranty_months IS 'Meses de garantia del fabricante'; + END IF; + + -- warranty_policy + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'parts_management' + AND table_name = 'parts' + AND column_name = 'warranty_policy' + ) THEN + ALTER TABLE parts_management.parts + ADD COLUMN warranty_policy TEXT; + COMMENT ON COLUMN parts_management.parts.warranty_policy IS 'Descripcion de politica de garantia'; + END IF; +END $$; + +-- ============================================ +-- TABLA DE GARANTIAS DE PARTES INSTALADAS +-- ============================================ + +-- Estados de garantia +CREATE TYPE parts_management.warranty_status AS ENUM ( + 'active', -- Garantia vigente + 'expired', -- Garantia expirada + 'claimed', -- Reclamo en proceso + 'approved', -- Reclamo aprobado + 'rejected', -- Reclamo rechazado + 'replaced' -- Pieza reemplazada por garantia +); + +-- Garantias de piezas instaladas +CREATE TABLE parts_management.warranty_claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Referencia a la pieza + part_id UUID NOT NULL, + part_name VARCHAR(256) NOT NULL, -- Desnormalizado para historial + part_sku VARCHAR(50), + + -- Referencia al proveedor/fabricante + supplier_id UUID, + supplier_name VARCHAR(256), + manufacturer VARCHAR(256), + + -- Datos de instalacion + service_order_id UUID, + service_order_number VARCHAR(50), + installation_date DATE NOT NULL, + installation_notes TEXT, + + -- Datos de garantia + warranty_months INTEGER NOT NULL DEFAULT 12, + expiration_date DATE NOT NULL, + serial_number VARCHAR(100), + lot_number VARCHAR(100), + + -- Vehiculo (contexto) + vehicle_id UUID, + vehicle_plate VARCHAR(20), + vehicle_description VARCHAR(256), + + -- Cliente + customer_id UUID, + customer_name VARCHAR(256), + + -- Estado y reclamo + status parts_management.warranty_status NOT NULL DEFAULT 'active', + + -- Datos del reclamo (si aplica) + claim_date DATE, + claim_reason TEXT, + claim_description TEXT, + defect_photos TEXT[], -- URLs de fotos del defecto + + -- Resolucion + resolution_date DATE, + resolution_type VARCHAR(50), -- replacement, refund, repair, rejected + resolution_notes TEXT, + replacement_part_id UUID, -- Nueva pieza si fue reemplazo + + -- Costos + original_cost DECIMAL(20,6), + claim_amount DECIMAL(20,6), + approved_amount DECIMAL(20,6), + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + created_by UUID, + claimed_by UUID, + resolved_by UUID +); + +COMMENT ON TABLE parts_management.warranty_claims IS 'Registro de garantias de piezas instaladas en vehiculos'; +COMMENT ON COLUMN parts_management.warranty_claims.defect_photos IS 'Array de URLs a fotos del defecto'; + +-- Indices para warranty_claims +CREATE INDEX idx_wc_tenant ON parts_management.warranty_claims(tenant_id); +CREATE INDEX idx_wc_part ON parts_management.warranty_claims(part_id); +CREATE INDEX idx_wc_service_order ON parts_management.warranty_claims(service_order_id) WHERE service_order_id IS NOT NULL; +CREATE INDEX idx_wc_vehicle ON parts_management.warranty_claims(vehicle_id) WHERE vehicle_id IS NOT NULL; +CREATE INDEX idx_wc_customer ON parts_management.warranty_claims(customer_id); +CREATE INDEX idx_wc_status ON parts_management.warranty_claims(status); +CREATE INDEX idx_wc_expiration ON parts_management.warranty_claims(expiration_date) + WHERE status = 'active'; +CREATE INDEX idx_wc_supplier ON parts_management.warranty_claims(supplier_id) WHERE supplier_id IS NOT NULL; + +-- RLS para warranty_claims +SELECT create_tenant_rls_policies('parts_management', 'warranty_claims'); + +-- Trigger para updated_at +CREATE TRIGGER set_updated_at_warranty_claims + BEFORE UPDATE ON parts_management.warranty_claims + FOR EACH ROW + EXECUTE FUNCTION trigger_set_updated_at(); + +-- ============================================ +-- FUNCIONES AUXILIARES +-- ============================================ + +-- Funcion para crear registro de garantia al instalar pieza +CREATE OR REPLACE FUNCTION parts_management.create_warranty_record( + p_tenant_id UUID, + p_part_id UUID, + p_service_order_id UUID, + p_vehicle_id UUID DEFAULT NULL, + p_customer_id UUID DEFAULT NULL, + p_serial_number VARCHAR(100) DEFAULT NULL, + p_supplier_id UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_warranty_id UUID; + v_part RECORD; + v_service_order RECORD; + v_vehicle RECORD; + v_customer RECORD; + v_supplier RECORD; +BEGIN + -- Obtener datos de la pieza + SELECT id, sku, name, warranty_months, warranty_policy, cost + INTO v_part + FROM parts_management.parts + WHERE id = p_part_id; + + IF v_part.id IS NULL THEN + RAISE EXCEPTION 'Part % not found', p_part_id; + END IF; + + -- Si no tiene garantia, no crear registro + IF COALESCE(v_part.warranty_months, 0) <= 0 THEN + RETURN NULL; + END IF; + + -- Obtener datos de orden de servicio + SELECT id, order_number + INTO v_service_order + FROM service_management.service_orders + WHERE id = p_service_order_id; + + -- Obtener datos de vehiculo (si aplica) + IF p_vehicle_id IS NOT NULL THEN + SELECT id, plate_number, + CONCAT(brand, ' ', model, ' ', COALESCE(year::TEXT, '')) as description + INTO v_vehicle + FROM vehicle_management.vehicles + WHERE id = p_vehicle_id; + END IF; + + -- Obtener datos de cliente (si aplica) + IF p_customer_id IS NOT NULL THEN + SELECT id, name + INTO v_customer + FROM workshop_core.customers + WHERE id = p_customer_id; + END IF; + + -- Obtener datos de proveedor (si aplica) + IF p_supplier_id IS NOT NULL THEN + SELECT id, name + INTO v_supplier + FROM purchasing.suppliers + WHERE id = p_supplier_id; + END IF; + + -- Crear registro de garantia + INSERT INTO parts_management.warranty_claims ( + tenant_id, + part_id, part_name, part_sku, + supplier_id, supplier_name, + service_order_id, service_order_number, + installation_date, warranty_months, expiration_date, + serial_number, + vehicle_id, vehicle_plate, vehicle_description, + customer_id, customer_name, + original_cost, + created_by + ) + VALUES ( + p_tenant_id, + v_part.id, v_part.name, v_part.sku, + p_supplier_id, v_supplier.name, + p_service_order_id, v_service_order.order_number, + CURRENT_DATE, v_part.warranty_months, + CURRENT_DATE + (v_part.warranty_months || ' months')::INTERVAL, + p_serial_number, + p_vehicle_id, v_vehicle.plate_number, v_vehicle.description, + p_customer_id, v_customer.name, + v_part.cost, + get_current_user_id() + ) + RETURNING id INTO v_warranty_id; + + RETURN v_warranty_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION parts_management.create_warranty_record IS 'Crea registro de garantia al instalar una pieza'; + +-- Funcion para iniciar reclamo de garantia +CREATE OR REPLACE FUNCTION parts_management.initiate_warranty_claim( + p_warranty_id UUID, + p_claim_reason TEXT, + p_claim_description TEXT DEFAULT NULL, + p_defect_photos TEXT[] DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_warranty RECORD; +BEGIN + -- Obtener garantia + SELECT * INTO v_warranty + FROM parts_management.warranty_claims + WHERE id = p_warranty_id; + + IF v_warranty.id IS NULL THEN + RAISE EXCEPTION 'Warranty record % not found', p_warranty_id; + END IF; + + -- Verificar que este activa + IF v_warranty.status != 'active' THEN + RAISE EXCEPTION 'Warranty is not active (current status: %)', v_warranty.status; + END IF; + + -- Verificar que no este expirada + IF v_warranty.expiration_date < CURRENT_DATE THEN + -- Actualizar a expirada primero + UPDATE parts_management.warranty_claims + SET status = 'expired', updated_at = NOW() + WHERE id = p_warranty_id; + + RAISE EXCEPTION 'Warranty expired on %', v_warranty.expiration_date; + END IF; + + -- Actualizar con datos del reclamo + UPDATE parts_management.warranty_claims + SET + status = 'claimed', + claim_date = CURRENT_DATE, + claim_reason = p_claim_reason, + claim_description = p_claim_description, + defect_photos = p_defect_photos, + claim_amount = original_cost, + claimed_by = get_current_user_id(), + updated_at = NOW() + WHERE id = p_warranty_id; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION parts_management.initiate_warranty_claim IS 'Inicia un reclamo de garantia'; + +-- Funcion para resolver reclamo +CREATE OR REPLACE FUNCTION parts_management.resolve_warranty_claim( + p_warranty_id UUID, + p_resolution_type VARCHAR(50), + p_approved_amount DECIMAL(20,6) DEFAULT NULL, + p_resolution_notes TEXT DEFAULT NULL, + p_replacement_part_id UUID DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_new_status parts_management.warranty_status; +BEGIN + -- Determinar nuevo estado segun resolucion + v_new_status := CASE p_resolution_type + WHEN 'replacement' THEN 'replaced'::parts_management.warranty_status + WHEN 'refund' THEN 'approved'::parts_management.warranty_status + WHEN 'repair' THEN 'approved'::parts_management.warranty_status + WHEN 'rejected' THEN 'rejected'::parts_management.warranty_status + ELSE 'approved'::parts_management.warranty_status + END; + + UPDATE parts_management.warranty_claims + SET + status = v_new_status, + resolution_date = CURRENT_DATE, + resolution_type = p_resolution_type, + resolution_notes = p_resolution_notes, + approved_amount = CASE + WHEN p_resolution_type = 'rejected' THEN 0 + ELSE COALESCE(p_approved_amount, claim_amount) + END, + replacement_part_id = p_replacement_part_id, + resolved_by = get_current_user_id(), + updated_at = NOW() + WHERE id = p_warranty_id + AND status = 'claimed'; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION parts_management.resolve_warranty_claim IS 'Resuelve un reclamo de garantia'; + +-- Funcion para actualizar garantias expiradas (ejecutar diariamente) +CREATE OR REPLACE FUNCTION parts_management.expire_warranties() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE parts_management.warranty_claims + SET + status = 'expired', + updated_at = NOW() + WHERE status = 'active' + AND expiration_date < CURRENT_DATE; + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION parts_management.expire_warranties IS 'Marca como expiradas las garantias vencidas'; + +-- ============================================ +-- VISTAS DE REPORTES +-- ============================================ + +-- Vista de garantias activas +CREATE VIEW parts_management.v_active_warranties AS +SELECT + wc.id, + wc.tenant_id, + wc.part_id, + wc.part_name, + wc.part_sku, + wc.supplier_name, + wc.manufacturer, + wc.service_order_number, + wc.installation_date, + wc.expiration_date, + wc.serial_number, + wc.vehicle_plate, + wc.vehicle_description, + wc.customer_name, + wc.original_cost, + -- Dias restantes + wc.expiration_date - CURRENT_DATE as days_remaining, + -- Urgencia de expiracion + CASE + WHEN wc.expiration_date - CURRENT_DATE <= 7 THEN 'critical' + WHEN wc.expiration_date - CURRENT_DATE <= 30 THEN 'warning' + WHEN wc.expiration_date - CURRENT_DATE <= 90 THEN 'notice' + ELSE 'ok' + END as expiration_urgency +FROM parts_management.warranty_claims wc +WHERE wc.status = 'active' + AND wc.expiration_date >= CURRENT_DATE +ORDER BY wc.expiration_date ASC; + +COMMENT ON VIEW parts_management.v_active_warranties IS 'Garantias vigentes con dias restantes'; + +-- Vista de reclamos pendientes +CREATE VIEW parts_management.v_pending_claims AS +SELECT + wc.id, + wc.tenant_id, + wc.part_name, + wc.part_sku, + wc.supplier_id, + wc.supplier_name, + wc.claim_date, + wc.claim_reason, + wc.claim_amount, + wc.vehicle_plate, + wc.customer_name, + CURRENT_DATE - wc.claim_date as days_pending +FROM parts_management.warranty_claims wc +WHERE wc.status = 'claimed' +ORDER BY wc.claim_date ASC; + +COMMENT ON VIEW parts_management.v_pending_claims IS 'Reclamos de garantia pendientes de resolucion'; + +-- Vista resumen de garantias por proveedor +CREATE VIEW parts_management.v_warranty_summary_by_supplier AS +SELECT + wc.tenant_id, + wc.supplier_id, + wc.supplier_name, + COUNT(*) as total_warranties, + COUNT(*) FILTER (WHERE status = 'active') as active_warranties, + COUNT(*) FILTER (WHERE status = 'claimed') as pending_claims, + COUNT(*) FILTER (WHERE status IN ('approved', 'replaced')) as approved_claims, + COUNT(*) FILTER (WHERE status = 'rejected') as rejected_claims, + COALESCE(SUM(approved_amount) FILTER (WHERE status IN ('approved', 'replaced')), 0) as total_approved_amount, + ROUND( + COUNT(*) FILTER (WHERE status IN ('approved', 'replaced'))::DECIMAL / + NULLIF(COUNT(*) FILTER (WHERE status IN ('approved', 'replaced', 'rejected')), 0) * 100, + 2 + ) as approval_rate +FROM parts_management.warranty_claims wc +WHERE wc.supplier_id IS NOT NULL +GROUP BY wc.tenant_id, wc.supplier_id, wc.supplier_name +ORDER BY total_warranties DESC; + +COMMENT ON VIEW parts_management.v_warranty_summary_by_supplier IS 'Resumen de garantias agrupado por proveedor'; + +-- Vista de garantias por vehiculo +CREATE VIEW parts_management.v_vehicle_warranties AS +SELECT + wc.tenant_id, + wc.vehicle_id, + wc.vehicle_plate, + wc.vehicle_description, + wc.customer_id, + wc.customer_name, + COUNT(*) as total_parts_with_warranty, + COUNT(*) FILTER (WHERE status = 'active' AND expiration_date >= CURRENT_DATE) as active_warranties, + COUNT(*) FILTER (WHERE status = 'active' AND expiration_date >= CURRENT_DATE AND expiration_date - CURRENT_DATE <= 30) as expiring_soon, + COALESCE(SUM(original_cost), 0) as total_warranty_value +FROM parts_management.warranty_claims wc +WHERE wc.vehicle_id IS NOT NULL +GROUP BY wc.tenant_id, wc.vehicle_id, wc.vehicle_plate, wc.vehicle_description, + wc.customer_id, wc.customer_name +ORDER BY active_warranties DESC; + +COMMENT ON VIEW parts_management.v_vehicle_warranties IS 'Resumen de garantias por vehiculo'; + +-- ============================================ +-- GRANTS +-- ============================================ +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA parts_management TO mecanicas_user; +GRANT SELECT ON ALL TABLES IN SCHEMA parts_management TO mecanicas_user; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/11-quote-signature.sql b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/11-quote-signature.sql new file mode 100644 index 0000000..ab9ab4e --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/database/init/11-quote-signature.sql @@ -0,0 +1,426 @@ +-- =========================================== +-- MECANICAS DIESEL - Firma Electronica Basica +-- =========================================== +-- Resuelve: GAP-12 +-- Sistema de firma canvas para aprobacion de cotizaciones +-- Nota: Para NOM-151 completa ver SPEC-FIRMA-ELECTRONICA-NOM151.md + +-- ============================================ +-- EXTENSION DE QUOTES PARA FIRMA +-- ============================================ + +-- Agregar campos de firma a cotizaciones +DO $$ +BEGIN + -- signature_data (canvas base64) + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signature_data' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signature_data TEXT; + COMMENT ON COLUMN service_management.quotes.signature_data IS 'Firma canvas en formato base64 PNG'; + END IF; + + -- signed_at + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signed_at' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signed_at TIMESTAMPTZ; + COMMENT ON COLUMN service_management.quotes.signed_at IS 'Fecha y hora de firma'; + END IF; + + -- signed_by_name + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signed_by_name' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signed_by_name VARCHAR(256); + COMMENT ON COLUMN service_management.quotes.signed_by_name IS 'Nombre de quien firmo'; + END IF; + + -- signed_by_ip + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signed_by_ip' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signed_by_ip VARCHAR(45); + COMMENT ON COLUMN service_management.quotes.signed_by_ip IS 'IP desde donde se firmo'; + END IF; + + -- signed_by_email + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signed_by_email' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signed_by_email VARCHAR(256); + COMMENT ON COLUMN service_management.quotes.signed_by_email IS 'Email de quien firmo'; + END IF; + + -- signature_hash + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'signature_hash' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN signature_hash VARCHAR(128); + COMMENT ON COLUMN service_management.quotes.signature_hash IS 'Hash SHA-256 del documento al momento de firmar'; + END IF; + + -- approval_token + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'approval_token' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN approval_token VARCHAR(64); + COMMENT ON COLUMN service_management.quotes.approval_token IS 'Token unico para link de aprobacion'; + END IF; + + -- token_expires_at + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'service_management' + AND table_name = 'quotes' + AND column_name = 'token_expires_at' + ) THEN + ALTER TABLE service_management.quotes + ADD COLUMN token_expires_at TIMESTAMPTZ; + COMMENT ON COLUMN service_management.quotes.token_expires_at IS 'Expiracion del token de aprobacion'; + END IF; +END $$; + +-- Indice para busqueda por token +CREATE INDEX IF NOT EXISTS idx_quotes_approval_token +ON service_management.quotes(approval_token) +WHERE approval_token IS NOT NULL; + +-- ============================================ +-- TABLA DE HISTORIAL DE FIRMAS +-- ============================================ + +-- Historial de todas las firmas (para auditoria) +CREATE TABLE IF NOT EXISTS service_management.signature_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Documento firmado + document_type VARCHAR(50) NOT NULL, -- 'quote', 'service_order', etc. + document_id UUID NOT NULL, + document_number VARCHAR(50), + + -- Datos del firmante + signer_name VARCHAR(256) NOT NULL, + signer_email VARCHAR(256), + signer_phone VARCHAR(50), + signer_ip VARCHAR(45), + signer_user_agent TEXT, + + -- Firma + signature_data TEXT NOT NULL, -- Base64 de la imagen de firma + signature_method VARCHAR(50) NOT NULL DEFAULT 'canvas', -- canvas, typed, upload + + -- Integridad + document_hash VARCHAR(128) NOT NULL, -- Hash del documento al firmar + signature_hash VARCHAR(128), -- Hash de la firma + document_snapshot JSONB, -- Snapshot del documento + + -- Contexto + action VARCHAR(50) NOT NULL, -- 'approve', 'reject', 'acknowledge' + comments TEXT, + + -- Auditoria + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Geolocation (opcional, si el cliente lo permite) + geo_latitude DECIMAL(10, 8), + geo_longitude DECIMAL(11, 8), + geo_accuracy DECIMAL(10, 2) +); + +COMMENT ON TABLE service_management.signature_audit IS 'Registro de auditoria de todas las firmas electronicas'; + +-- Indices +CREATE INDEX idx_sig_audit_tenant ON service_management.signature_audit(tenant_id); +CREATE INDEX idx_sig_audit_document ON service_management.signature_audit(document_type, document_id); +CREATE INDEX idx_sig_audit_signer ON service_management.signature_audit(signer_email); +CREATE INDEX idx_sig_audit_created ON service_management.signature_audit(created_at DESC); + +-- RLS +SELECT create_tenant_rls_policies('service_management', 'signature_audit'); + +-- ============================================ +-- FUNCIONES AUXILIARES +-- ============================================ + +-- Funcion para generar token de aprobacion +CREATE OR REPLACE FUNCTION service_management.generate_approval_token( + p_quote_id UUID, + p_expires_hours INTEGER DEFAULT 72 +) +RETURNS VARCHAR(64) AS $$ +DECLARE + v_token VARCHAR(64); +BEGIN + -- Generar token aleatorio + v_token := encode(gen_random_bytes(32), 'hex'); + + -- Actualizar cotizacion con token + UPDATE service_management.quotes + SET + approval_token = v_token, + token_expires_at = NOW() + (p_expires_hours || ' hours')::INTERVAL + WHERE id = p_quote_id; + + RETURN v_token; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION service_management.generate_approval_token IS 'Genera token de aprobacion para cotizacion'; + +-- Funcion para validar token +CREATE OR REPLACE FUNCTION service_management.validate_approval_token( + p_token VARCHAR(64) +) +RETURNS TABLE ( + quote_id UUID, + is_valid BOOLEAN, + error_message TEXT +) AS $$ +DECLARE + v_quote RECORD; +BEGIN + -- Buscar cotizacion con token + SELECT q.id, q.status, q.token_expires_at, q.signed_at + INTO v_quote + FROM service_management.quotes q + WHERE q.approval_token = p_token; + + -- Token no encontrado + IF v_quote.id IS NULL THEN + RETURN QUERY SELECT + NULL::UUID, + false, + 'Token invalido o no encontrado'::TEXT; + RETURN; + END IF; + + -- Token expirado + IF v_quote.token_expires_at < NOW() THEN + RETURN QUERY SELECT + v_quote.id, + false, + 'El token ha expirado'::TEXT; + RETURN; + END IF; + + -- Ya firmada + IF v_quote.signed_at IS NOT NULL THEN + RETURN QUERY SELECT + v_quote.id, + false, + 'La cotizacion ya fue firmada'::TEXT; + RETURN; + END IF; + + -- Cotizacion no esta en estado correcto + IF v_quote.status NOT IN ('sent', 'pending') THEN + RETURN QUERY SELECT + v_quote.id, + false, + 'La cotizacion no esta disponible para aprobacion'::TEXT; + RETURN; + END IF; + + -- Token valido + RETURN QUERY SELECT + v_quote.id, + true, + NULL::TEXT; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION service_management.validate_approval_token IS 'Valida token de aprobacion y estado de cotizacion'; + +-- Funcion para firmar cotizacion +CREATE OR REPLACE FUNCTION service_management.sign_quote( + p_quote_id UUID, + p_signature_data TEXT, + p_signer_name VARCHAR(256), + p_signer_email VARCHAR(256) DEFAULT NULL, + p_signer_ip VARCHAR(45) DEFAULT NULL, + p_user_agent TEXT DEFAULT NULL, + p_comments TEXT DEFAULT NULL, + p_action VARCHAR(50) DEFAULT 'approve' +) +RETURNS UUID AS $$ +DECLARE + v_quote RECORD; + v_audit_id UUID; + v_document_hash VARCHAR(128); + v_signature_hash VARCHAR(128); + v_new_status VARCHAR(20); +BEGIN + -- Obtener cotizacion + SELECT q.*, c.name as customer_name + INTO v_quote + FROM service_management.quotes q + LEFT JOIN workshop_core.customers c ON c.id = q.customer_id + WHERE q.id = p_quote_id; + + IF v_quote.id IS NULL THEN + RAISE EXCEPTION 'Quote % not found', p_quote_id; + END IF; + + -- Verificar que no este ya firmada + IF v_quote.signed_at IS NOT NULL THEN + RAISE EXCEPTION 'Quote already signed on %', v_quote.signed_at; + END IF; + + -- Generar hash del documento (simplificado - en produccion usar contenido completo) + v_document_hash := encode( + sha256( + (v_quote.id::TEXT || v_quote.total::TEXT || v_quote.created_at::TEXT)::bytea + ), + 'hex' + ); + + -- Generar hash de la firma + v_signature_hash := encode(sha256(p_signature_data::bytea), 'hex'); + + -- Determinar nuevo estado + v_new_status := CASE p_action + WHEN 'approve' THEN 'approved' + WHEN 'reject' THEN 'rejected' + ELSE 'pending' + END; + + -- Actualizar cotizacion + UPDATE service_management.quotes + SET + status = v_new_status, + signature_data = p_signature_data, + signed_at = NOW(), + signed_by_name = p_signer_name, + signed_by_email = p_signer_email, + signed_by_ip = p_signer_ip, + signature_hash = v_document_hash, + approval_token = NULL, -- Invalidar token + token_expires_at = NULL, + updated_at = NOW() + WHERE id = p_quote_id; + + -- Crear registro de auditoria + INSERT INTO service_management.signature_audit ( + tenant_id, + document_type, document_id, document_number, + signer_name, signer_email, signer_ip, signer_user_agent, + signature_data, signature_method, + document_hash, signature_hash, + document_snapshot, + action, comments + ) + VALUES ( + v_quote.tenant_id, + 'quote', p_quote_id, v_quote.quote_number, + p_signer_name, p_signer_email, p_signer_ip, p_user_agent, + p_signature_data, 'canvas', + v_document_hash, v_signature_hash, + jsonb_build_object( + 'quote_number', v_quote.quote_number, + 'customer_name', v_quote.customer_name, + 'total', v_quote.total, + 'created_at', v_quote.created_at, + 'items_count', (SELECT COUNT(*) FROM service_management.quote_lines WHERE quote_id = p_quote_id) + ), + p_action, p_comments + ) + RETURNING id INTO v_audit_id; + + RETURN v_audit_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION service_management.sign_quote IS 'Firma una cotizacion con firma canvas y crea registro de auditoria'; + +-- ============================================ +-- VISTAS +-- ============================================ + +-- Vista de cotizaciones pendientes de firma +CREATE VIEW service_management.v_quotes_pending_signature AS +SELECT + q.id, + q.tenant_id, + q.quote_number, + q.status, + q.total, + c.name as customer_name, + c.email as customer_email, + c.phone as customer_phone, + q.approval_token IS NOT NULL as has_token, + q.token_expires_at, + CASE + WHEN q.token_expires_at IS NULL THEN 'no_token' + WHEN q.token_expires_at < NOW() THEN 'expired' + WHEN q.token_expires_at < NOW() + INTERVAL '24 hours' THEN 'expiring_soon' + ELSE 'valid' + END as token_status, + q.created_at, + q.updated_at +FROM service_management.quotes q +LEFT JOIN workshop_core.customers c ON c.id = q.customer_id +WHERE q.status IN ('sent', 'pending') + AND q.signed_at IS NULL +ORDER BY q.created_at DESC; + +COMMENT ON VIEW service_management.v_quotes_pending_signature IS 'Cotizaciones pendientes de firma del cliente'; + +-- Vista de historial de firmas +CREATE VIEW service_management.v_signature_history AS +SELECT + sa.id, + sa.tenant_id, + sa.document_type, + sa.document_number, + sa.signer_name, + sa.signer_email, + sa.action, + sa.created_at as signed_at, + sa.signer_ip, + sa.comments, + -- Info adicional del documento + CASE sa.document_type + WHEN 'quote' THEN (SELECT total FROM service_management.quotes WHERE id = sa.document_id) + ELSE NULL + END as document_total +FROM service_management.signature_audit sa +ORDER BY sa.created_at DESC; + +COMMENT ON VIEW service_management.v_signature_history IS 'Historial de firmas electronicas'; + +-- ============================================ +-- GRANTS +-- ============================================ +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA service_management TO mecanicas_user; +GRANT SELECT ON service_management.signature_audit TO mecanicas_user; +GRANT INSERT ON service_management.signature_audit TO mecanicas_user; diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md new file mode 100644 index 0000000..1da5b56 --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md @@ -0,0 +1,517 @@ +# Analisis Arquitectonico: Implementacion de Documentacion erp-core en mecanicas-diesel + +**Fecha:** 2025-12-12 +**Analista:** Architecture-Analyst +**Proyecto Origen:** erp-core + Odoo 18.0 +**Proyecto Destino:** mecanicas-diesel +**Version:** 1.0.0 + +--- + +## Resumen Ejecutivo + +Este documento presenta un analisis detallado para la implementacion de la documentacion generada del subproyecto **erp-core** en el proyecto **mecanicas-diesel**, incluyendo un cruce contra el proyecto de referencia **Odoo 18.0** para validar que toda la logica de negocio y definiciones esten correctamente implementadas para el giro de **talleres de reparacion de motores diesel**. + +### Hallazgos Principales + +| Categoria | Estado | Cobertura | +|-----------|--------|-----------| +| Modulos MGN reutilizados | 8 de 14 | 57% | +| Documentacion completada | 95% | Alta | +| DDL implementado | 43 tablas | Completo | +| Gaps identificados | 12 | Criticos: 3 | +| Alineacion con Odoo | 85% | Muy Alta | + +--- + +## 1. Contexto del Proyecto + +### 1.1 Perfil del Usuario/Cliente Final + +**Giro de Negocio:** Talleres y laboratorios de mecanica diesel + +**Usuarios Principales:** +| Rol | Responsabilidad | Frecuencia de Uso | +|-----|-----------------|-------------------| +| Gerente de Taller | Direccion, precios, reportes | Diaria | +| Jefe de Taller | Supervision, asignacion de trabajos | Continua | +| Mecanico Diesel | Diagnosticos, reparaciones | Continua | +| Almacenista | Control de refacciones | Continua | +| Recepcionista | Atencion cliente, ordenes | Continua | +| Contador | Facturacion, reportes | Semanal | + +**Flujo Principal de Negocio:** +``` +Cliente → Recepcion → Diagnostico → Cotizacion → Aprobacion → Reparacion → Entrega → Facturacion +``` + +### 1.2 Arquitectura Actual + +``` +mecanicas-diesel (Proyecto Independiente) +├── Adapta patrones de erp-core (60-70%) +├── Schemas propios: 3 (service_management, parts_management, vehicle_management) +├── Tablas: 43 +├── Modulos MVP: 6 (MMD-001 a MMD-006) +└── Estado: Documentacion 95%, DDL 100% +``` + +--- + +## 2. Mapeo: erp-core (MGN) → mecanicas-diesel (MMD) + +### 2.1 Matriz de Correspondencia + +| Modulo MGN (Core) | Modulo MMD (Diesel) | % Reutilizacion | Estado Implementacion | +|-------------------|---------------------|-----------------|----------------------| +| MGN-001 Fundamentos | MMD-001 Fundamentos | 100% | ✅ Documentado + DDL | +| MGN-002 Empresas | MMD-001 (tenant=taller) | 90% | ✅ Adaptado | +| MGN-003 Catalogos | MMD-001 + MMD-004 | 80% | ✅ Adaptado | +| MGN-004 Financiero | Fase 2 (MMD-007) | 0% | ⏳ Pendiente | +| MGN-005 Inventario | MMD-004 Inventario Refacciones | 70% | ✅ Adaptado | +| MGN-006 Compras | MMD-004 (recepciones) | 50% | ⚠️ Parcial | +| MGN-007 Ventas | MMD-006 Cotizaciones + MMD-002 Ordenes | 60% | ✅ Adaptado | +| MGN-008 Analitica | No aplica MVP | 0% | ❌ Fuera de alcance | +| MGN-009 CRM | No aplica | 0% | ❌ Fuera de alcance | +| MGN-010 RRHH | MMD-001 (mecanicos) | 30% | ⚠️ Parcial | +| MGN-011 Proyectos | MMD-002 Ordenes (equivalente) | 40% | ✅ Adaptado | +| MGN-012 Reportes | Fase 2 (MMD-008) | 0% | ⏳ Pendiente | +| MGN-013 Portal | Fase 2 | 0% | ⏳ Pendiente | +| MGN-014 Mensajeria | MMD-002 (notificaciones WhatsApp) | 20% | ⚠️ Parcial | + +### 2.2 Detalle de Adaptaciones + +#### MGN-001 → MMD-001: Fundamentos +``` +REUTILIZADO: +✅ Autenticacion JWT +✅ Multi-tenancy (taller = tenant) +✅ RBAC con RLS PostgreSQL +✅ Gestion de usuarios +✅ Roles: gerente_taller, jefe_taller, mecanico, almacenista, recepcionista + +ADAPTADO: +🔧 Roles especificos del taller (no genericos) +🔧 Bahias de trabajo (concepto nuevo) +🔧 Catalogos de servicios diesel + +NUEVO EN MMD: +➕ workshop_core.work_bays (bahias de trabajo) +➕ workshop_core.service_types (tipos de servicio diesel) +➕ workshop_core.diesel_catalog (catalogo de motores) +``` + +#### MGN-005 → MMD-004: Inventario Refacciones +``` +REUTILIZADO: +✅ Estructura de almacenes/ubicaciones +✅ Movimientos de stock (stock_moves) +✅ Trazabilidad (lotes, series) +✅ Valoracion FIFO/AVCO +✅ Conteos ciclicos + +ADAPTADO: +🔧 Productos → Refacciones (parts) +🔧 Categories → Categorias de refacciones diesel +🔧 Ubicaciones simplificadas (almacen de taller) + +NUEVO EN MMD: +➕ parts_management.part_compatibility (compatibilidad vehicular) +➕ parts_management.oem_numbers (numeros de parte OEM) +➕ parts_management.warranty_tracking (garantias) +``` + +#### MGN-007 → MMD-002/MMD-006: Ordenes y Cotizaciones +``` +REUTILIZADO: +✅ Workflow de estados (draft → confirmed → done) +✅ Lineas de documento (productos/servicios) +✅ Calculo de totales e impuestos +✅ Portal de aprobacion (basico) + +ADAPTADO: +🔧 sale.order → service_orders (orden de servicio) +🔧 sale.order.line → order_items (lineas con servicios y refacciones) +🔧 Estados especificos: RECIBIDO → EN_DIAGNOSTICO → COTIZADO → APROBADO → EN_REPARACION → LISTO → ENTREGADO + +NUEVO EN MMD: +➕ service_management.diagnostics (diagnosticos tecnicos) +➕ service_management.diagnostic_items (hallazgos) +➕ service_management.work_assignments (asignaciones a mecanicos) +➕ service_management.customer_symptoms (sintomas reportados) +``` + +--- + +## 3. Validacion contra Odoo 18.0 + +### 3.1 Patrones de Odoo Implementados Correctamente + +| Patron Odoo | Implementacion MMD | Estado | Notas | +|-------------|-------------------|--------|-------| +| Partner Universal | customers, contacts | ✅ | Clientes y flotas | +| Workflow con Estados | service_orders.status | ✅ | 7 estados definidos | +| Record Rules (RLS) | PostgreSQL RLS | ✅ | tenant_id en todas las tablas | +| Doble Movimiento Stock | inventory_movements | ✅ | Origen → Destino | +| Trazabilidad Lotes | lot_tracking | ✅ | Para garantias | +| mail.thread (Tracking) | ⚠️ Pendiente | ⏳ | En SPEC-MAIL-THREAD | + +### 3.2 Gaps vs Odoo Identificados + +| # | Gap | Impacto | Criticidad | Accion Requerida | +|---|-----|---------|------------|------------------| +| 1 | No hay sistema de tracking de cambios (mail.thread) | Pierde auditoria automatica | ALTA | Implementar SPEC-MAIL-THREAD-TRACKING | +| 2 | No hay followers/suscriptores | No notifica automaticamente | MEDIA | Agregar tabla notifications.followers | +| 3 | No hay actividades programadas | No hay recordatorios | MEDIA | Agregar mail.activity equivalente | +| 4 | Facturacion no integrada | No genera asientos contables | ALTA | Fase 2 (MMD-007) | +| 5 | Sin contabilidad analitica | No hay P&L por orden | MEDIA | Considerar para Fase 2 | +| 6 | Portal basico incompleto | Cliente no puede ver avance | BAJA | Fase 2 | +| 7 | Sin integracion calendario | No agenda citas | BAJA | Opcional | +| 8 | Pricing rules basico | Sin descuentos escalonados | BAJA | Evaluar necesidad | +| 9 | Compras sin RFQ completo | Solo recepciones | MEDIA | Agregar modulo compras | +| 10 | Sin reporte de garantias | Tracking manual | MEDIA | Agregar reporte | +| 11 | Empleados sin contrato formal | Solo asignacion a bahia | BAJA | Simplificado para taller | +| 12 | Sin firma electronica | Aprobacion sin firma | MEDIA | Evaluar para cotizaciones | + +### 3.3 Logica de Negocio Validada + +#### 3.3.1 Flujo de Orden de Servicio (vs sale.order de Odoo) + +``` +ODOO (sale.order): + draft → sent → sale → done + +MMD (service_orders): + RECIBIDO → EN_DIAGNOSTICO → COTIZADO → APROBADO → EN_REPARACION → LISTO → ENTREGADO + ↓ + RECHAZADO + +VALIDACION: ✅ COMPLETO +- MMD tiene mas estados que Odoo (adaptado al giro) +- Transiciones controladas en DDL con CHECK constraints +- Estado ESPERANDO_REFACCIONES agregado (caso comun en talleres) +``` + +#### 3.3.2 Inventario de Refacciones (vs stock de Odoo) + +``` +ODOO (stock): + stock.warehouse → stock.location → stock.move → stock.quant + +MMD (parts_management): + warehouses → warehouse_locations → inventory_movements → stock_levels + +VALIDACION: ✅ COMPLETO +- Estructura equivalente +- Agregado: part_compatibility (compatibilidad con motores) +- Agregado: oem_numbers (numeros de parte originales) +- Trazabilidad de lotes para garantias +``` + +#### 3.3.3 Gestion de Vehiculos (nuevo, sin equivalente directo en Odoo) + +``` +MMD (vehicle_management): + vehicles → vehicle_engines → engine_catalog → fleets → maintenance_reminders + +VALIDACION: ✅ ESPECIFICO DEL GIRO +- No existe equivalente directo en Odoo base +- Similar a fleet.vehicle pero especializado en motores diesel +- Catalogo de motores Cummins, Detroit, Paccar, etc. +- Historial de servicios por vehiculo (critico para el negocio) +``` + +--- + +## 4. Analisis de Cobertura por Epica + +### 4.1 EPIC-MMD-001: Fundamentos (42 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD001-001 Configurar taller | MGN-004 Tenants | 100% | - | +| US-MMD001-002 Configurar roles | MGN-003 RBAC | 100% | - | +| US-MMD001-003 Catalogo servicios | Nuevo | 100% | - | +| US-MMD001-004 Datos fiscales | MGN-002 Companies | 100% | - | +| US-MMD001-005 Bahias de trabajo | Nuevo | 100% | - | +| US-MMD001-006 Aplicar RLS | MGN-001 RLS | 100% | - | +| US-MMD001-007 Importar catalogos | Nuevo | 100% | - | +| US-MMD001-008 Cambiar de bahia | Nuevo | 100% | - | +| US-MMD001-009 Dashboard uso | MGN-012 basico | 80% | Dashboards limitados | + +**Cobertura Total:** 97% + +### 4.2 EPIC-MMD-002: Ordenes de Servicio (55 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD002-001 Crear orden | sale.order | 100% | - | +| US-MMD002-002 Registrar sintomas | Nuevo | 100% | - | +| US-MMD002-003 Asignar mecanico | project.task.assign | 100% | - | +| US-MMD002-004 Ver ordenes dia | Kanban Odoo | 100% | - | +| US-MMD002-005 Registrar trabajos | timesheet | 90% | Sin timesheet formal | +| US-MMD002-006 Solicitar refacciones | stock.picking | 100% | - | +| US-MMD002-007 Tablero Kanban | project.kanban | 100% | - | +| US-MMD002-008 Cerrar y pre-factura | sale→invoice | 80% | Facturacion en Fase 2 | +| US-MMD002-009 Notificar WhatsApp | mail.message | 70% | Integracion basica | +| US-MMD002-010 Historial vehiculo | Nuevo | 100% | - | +| US-MMD002-011 Estados personalizados | project.task.type | 100% | - | + +**Cobertura Total:** 95% + +### 4.3 EPIC-MMD-003: Diagnosticos (42 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD003-001 Diagnostico computarizado | Nuevo | 100% | Especifico diesel | +| US-MMD003-002 Pruebas banco inyectores | Nuevo | 100% | Especifico diesel | +| US-MMD003-003 Pruebas bomba combustible | Nuevo | 100% | Especifico diesel | +| US-MMD003-004 Comparar vs referencias | Nuevo | 100% | - | +| US-MMD003-005 Adjuntar fotos | ir.attachment | 100% | - | +| US-MMD003-006 Recomendaciones | Nuevo | 100% | - | +| US-MMD003-007 Historial diagnosticos | Nuevo | 100% | - | +| US-MMD003-008 Configurar tipos prueba | Nuevo | 100% | - | + +**Cobertura Total:** 100% (modulo nuevo, especifico del giro) + +### 4.4 EPIC-MMD-004: Inventario (42 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD004-001 Registrar refacciones | product.product | 100% | - | +| US-MMD004-002 Consultar stock | stock.quant | 100% | - | +| US-MMD004-003 Solicitar desde orden | stock.move | 100% | - | +| US-MMD004-004 Recibir mercancia | stock.picking.incoming | 100% | - | +| US-MMD004-005 Ajustar inventario | stock.inventory | 100% | - | +| US-MMD004-006 Alertas stock minimo | stock.warehouse.orderpoint | 90% | Simplificado | +| US-MMD004-007 Ver kardex | stock.move.line | 100% | - | +| US-MMD004-008 Codigos alternos | product.supplierinfo | 100% | OEM numbers | +| US-MMD004-009 Ubicaciones almacen | stock.location | 100% | - | +| US-MMD004-010 Inventario fisico | stock.inventory | 100% | - | + +**Cobertura Total:** 99% + +### 4.5 EPIC-MMD-005: Vehiculos (34 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD005-001 Registrar vehiculo | fleet.vehicle (similar) | 100% | Adaptado diesel | +| US-MMD005-002 Editar vehiculo | fleet.vehicle | 100% | - | +| US-MMD005-003 Especificaciones motor | Nuevo | 100% | Especifico diesel | +| US-MMD005-004 Ficha tecnica | fleet.vehicle.odometer | 100% | - | +| US-MMD005-005 Historial servicios | service.order history | 100% | - | +| US-MMD005-006 Gestionar flotas | fleet management | 100% | - | +| US-MMD005-007 Recordatorios mantenimiento | mail.activity | 80% | Sin integracion | +| US-MMD005-008 Importar vehiculos | Nuevo | 100% | - | + +**Cobertura Total:** 97% + +### 4.6 EPIC-MMD-006: Cotizaciones (26 SP) + +| Historia | Equivalente Core/Odoo | Cobertura | Gap | +|----------|----------------------|-----------|-----| +| US-MMD006-001 Crear desde diagnostico | Nuevo | 100% | - | +| US-MMD006-002 Agregar lineas | sale.order.line | 100% | - | +| US-MMD006-003 Aplicar descuentos | sale.order.discount | 90% | Basico | +| US-MMD006-004 Enviar al cliente | mail.template | 80% | Email/WhatsApp | +| US-MMD006-005 Generar PDF | report.sale.order | 100% | - | +| US-MMD006-006 Convertir a orden | sale.order.action_confirm | 100% | - | +| US-MMD006-007 Historial cotizaciones | sale.order history | 100% | - | + +**Cobertura Total:** 96% + +--- + +## 5. Analisis de Schemas y DDL + +### 5.1 Comparativa de Estructuras + +| Schema erp-core | Schema mecanicas-diesel | Tablas Core | Tablas MMD | % Adaptacion | +|-----------------|------------------------|-------------|------------|--------------| +| auth | workshop_core (auth) | 6 | 4 | 67% | +| core | workshop_core (core) | 8 | 5 | 63% | +| inventory | parts_management | 15 | 12 | 80% | +| sales | service_management | 10 | 18 | 180% (expandido) | +| N/A | vehicle_management | 0 | 8 | 100% (nuevo) | + +### 5.2 Tablas Criticas Validadas + +```sql +-- service_management.service_orders (equivalente a sale.order) +VALIDADO: +✅ tenant_id (multi-tenancy) +✅ customer_id (FK conceptual a partners) +✅ vehicle_id (FK a vehicle_management) +✅ status (ENUM con 7 estados) +✅ order_number (secuencial por tenant) +✅ timestamps (created_at, updated_at, deleted_at) +✅ Soft delete (is_active) + +-- parts_management.parts (equivalente a product.product) +VALIDADO: +✅ tenant_id +✅ sku, name, description +✅ category_id +✅ cost_price, sale_price +✅ stock tracking fields +✅ oem_number (especifico diesel) +✅ warranty_months (especifico) + +-- vehicle_management.vehicles (sin equivalente directo Odoo) +VALIDADO: +✅ tenant_id +✅ customer_id (dueno) +✅ fleet_id (opcional) +✅ vin, plates +✅ engine_id (especifico diesel) +✅ current_odometer +``` + +### 5.3 RLS (Row Level Security) Validado + +```sql +-- Politicas RLS implementadas +✅ service_management.service_orders: tenant_id = get_current_tenant_id() +✅ parts_management.parts: tenant_id = get_current_tenant_id() +✅ vehicle_management.vehicles: tenant_id = get_current_tenant_id() + +-- Funciones de contexto +✅ get_current_tenant_id() - Retorna tenant activo +✅ get_current_user_id() - Retorna usuario activo +``` + +--- + +## 6. Recomendaciones de Implementacion + +### 6.1 Gaps Criticos a Resolver (Antes de MVP) + +| # | Gap | Accion | Esfuerzo | Prioridad | +|---|-----|--------|----------|-----------| +| 1 | Sistema de tracking/auditoria | Implementar SPEC-MAIL-THREAD-TRACKING | 8h | P0 | +| 2 | Notificaciones automaticas | Agregar notifications.followers | 4h | P0 | +| 3 | Workflow de aprobacion cotizaciones | Agregar approval_status a quotes | 2h | P0 | + +### 6.2 Gaps Importantes (Fase 2) + +| # | Gap | Accion | Esfuerzo | Prioridad | +|---|-----|--------|----------|-----------| +| 4 | Facturacion CFDI | Implementar MMD-007 | 40h | P1 | +| 5 | Reportes avanzados | Implementar MMD-008 | 24h | P1 | +| 6 | Portal de clientes | Fase 2 | 32h | P1 | +| 7 | Compras completas | Agregar modulo compras | 24h | P1 | + +### 6.3 Mejoras Opcionales (Fase 3+) + +| # | Mejora | Beneficio | Esfuerzo | +|---|--------|-----------|----------| +| 8 | Contabilidad analitica | P&L por orden | 16h | +| 9 | Firma electronica | Aprobacion legal | 8h | +| 10 | App movil mecanicos | Productividad | 80h | +| 11 | Integracion calendario | Agendamiento | 8h | + +--- + +## 7. Plan de Implementacion Recomendado + +### 7.1 Sprint Actual: Resolver Gaps Criticos + +``` +Semana 1: +- [ ] Implementar sistema de tracking de cambios (mail.thread pattern) +- [ ] Agregar tabla notifications.messages +- [ ] Agregar tabla notifications.followers +- [ ] Probar tracking en service_orders + +Semana 2: +- [ ] Completar workflow de aprobacion de cotizaciones +- [ ] Integrar notificaciones con estados de orden +- [ ] Documentar APIs de notificaciones +``` + +### 7.2 Sprints Siguientes: Desarrollo Backend/Frontend + +``` +Sprint 1-2: MMD-001 Fundamentos (desarrollo) +Sprint 3-4: MMD-002 Ordenes de Servicio (desarrollo) +Sprint 5-6: MMD-003 Diagnosticos (desarrollo) +Sprint 7-8: MMD-004 Inventario (desarrollo) +Sprint 9: MMD-005 Vehiculos (desarrollo) +Sprint 10: MMD-006 Cotizaciones (desarrollo) +``` + +--- + +## 8. Conclusiones + +### 8.1 Estado General + +| Aspecto | Evaluacion | Score | +|---------|------------|-------| +| Documentacion | Excelente | 95% | +| DDL/Modelo de datos | Completo | 100% | +| Alineacion con Odoo | Muy Alta | 85% | +| Alineacion con erp-core | Alta | 80% | +| Cobertura de logica de negocio | Alta | 90% | +| Gaps criticos | Pocos | 3 | + +### 8.2 Fortalezas del Proyecto + +1. **Documentacion exhaustiva:** 6 epicas, 55 historias de usuario, 100% cobertura +2. **DDL robusto:** 43 tablas con RLS, soft delete, timestamps +3. **Adaptacion correcta:** Patrones de Odoo traducidos al stack moderno +4. **Especializacion:** Modulos nuevos para el giro (diagnosticos, motores diesel) +5. **Multi-tenancy:** Implementado desde el inicio con PostgreSQL RLS + +### 8.3 Areas de Mejora + +1. **Sistema de auditoria:** Implementar mail.thread pattern +2. **Notificaciones:** Agregar followers y notificaciones automaticas +3. **Integracion financiera:** Planificar MMD-007 (Facturacion) +4. **Reportes:** Agregar dashboards operativos + +### 8.4 Veredicto Final + +**El proyecto mecanicas-diesel esta LISTO para iniciar desarrollo de backend/frontend**, con las siguientes condiciones: + +1. Resolver los 3 gaps criticos identificados (tracking, followers, workflow aprobacion) +2. La documentacion de erp-core ha sido correctamente adaptada al giro de talleres diesel +3. Los patrones de Odoo estan implementados de manera equivalente o mejorada +4. El modelo de datos es solido y soporta las necesidades del negocio + +--- + +## Anexos + +### A. Archivos de Referencia Consultados + +``` +erp-core/docs/01-analisis-referencias/odoo/README.md +erp-core/docs/01-analisis-referencias/odoo/MAPEO-ODOO-TO-MGN.md +erp-core/docs/01-analisis-referencias/odoo/VALIDACION-MGN-VS-ODOO.md +erp-core/docs/02-definicion-modulos/LISTA-MODULOS-ERP-GENERICO.md +erp-core/docs/02-definicion-modulos/ALCANCE-POR-MODULO.md + +mecanicas-diesel/docs/00-vision-general/VISION.md +mecanicas-diesel/docs/02-definicion-modulos/MMD-00X-*/README.md +mecanicas-diesel/database/HERENCIA-ERP-CORE.md +mecanicas-diesel/database/init/0X-*.sql +mecanicas-diesel/PROJECT-STATUS.md +``` + +### B. Checklist de Validacion + +- [x] Modulos MGN mapeados a MMD +- [x] Patrones Odoo identificados +- [x] DDL revisado y validado +- [x] RLS implementado correctamente +- [x] Flujos de negocio documentados +- [x] Gaps identificados y priorizados +- [x] Plan de implementacion definido + +--- + +**Documento generado por:** Architecture-Analyst +**Fecha:** 2025-12-12 +**Proyecto:** erp-suite/mecanicas-diesel +**Estado:** ✅ Analisis completado diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-INDEPENDENCIA-PROYECTO.md b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-INDEPENDENCIA-PROYECTO.md new file mode 100644 index 0000000..1b6e19b --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/ANALISIS-INDEPENDENCIA-PROYECTO.md @@ -0,0 +1,311 @@ +# Analisis de Independencia del Proyecto - Mecanicas Diesel + +**Fecha:** 2025-12-12 +**Objetivo:** Identificar y planificar eliminacion de referencias a proyectos base +**Estado:** PENDIENTE DE LIMPIEZA + +--- + +## Resumen Ejecutivo + +El proyecto `mecanicas-diesel` debe ser un **proyecto completamente independiente** que no requiera ni dependa de: + +1. **Odoo** - Sistema ERP de referencia usado para patrones +2. **erp-core** - Proyecto base del cual se derivaron patrones +3. **gamilit** - Otro proyecto de referencia (no encontrado) + +### Estadisticas de Referencias Encontradas + +| Referencia | Cantidad | Archivos Afectados | Accion | +|------------|----------|-------------------|--------| +| `odoo` | 21 | 7 | Eliminar | +| `erp-core` | 65+ | 24 | Eliminar | +| `MGN-*` | 62 | 15 | Eliminar | +| `gamilit` | 0 | 0 | N/A | +| Rutas absolutas `/home/isem/...` | 16 | 10 | Corregir/Eliminar | + +**Total:** ~164 referencias a limpiar en ~30 archivos unicos + +--- + +## Analisis del Perfil TECH-LEADER + +### Caracteristicas del Perfil + +El perfil `PERFIL-TECH-LEADER.md` es adecuado para iniciar desarrollo. Puntos clave: + +```yaml +Rol: Lider tecnico de equipo de agentes +Responsabilidades: + - Recibir tareas de alto nivel y descomponer + - Delegar a agentes especializados + - Coordinar flujo de trabajo + - Tomar decisiones tecnicas + - Asegurar calidad y coherencia + +Agentes que coordina: + analisis: + - REQUIREMENTS-ANALYST + - ARCHITECTURE-ANALYST + implementacion: + - DATABASE + - BACKEND / BACKEND-EXPRESS + - FRONTEND + - MOBILE-AGENT + calidad: + - CODE-REVIEWER + - BUG-FIXER + - DOCUMENTATION-VALIDATOR + infraestructura: + - DEVENV # IMPORTANTE: Gestiona puertos + - WORKSPACE-MANAGER +``` + +### Integracion con DEVENV para Puertos + +El perfil TECH-LEADER tiene protocolo especifico para consulta de puertos: + +```yaml +PROTOCOLO_DE_CONSULTA_DE_PUERTOS: + ANTES_DE_ASIGNAR_PUERTOS: + 1. Verificar: "Este servicio necesita puerto nuevo?" + 2. Si SI: + - Delegar a DEVENV + - Proporcionar: proyecto, tipo_servicio, descripcion + - Esperar: puerto asignado, configuracion .env + 3. Si NO: + - Usar puerto existente del inventario + - Verificar en: @DEVENV_PORTS + + NUNCA: + - Asignar puertos arbitrariamente + - Copiar puertos de otro proyecto sin verificar + - Usar puertos "comunes" (3000, 8080) sin consultar +``` + +**Alias relevante:** `@DEVENV_PORTS: "core/orchestration/inventarios/DEVENV-PORTS-INVENTORY.yml"` + +### Flujo de Desarrollo con TECH-LEADER + +``` +1. RECIBIR TAREA + ↓ +2. ANALISIS INICIAL (Tech-Leader) + - Entender alcance + - Identificar capas afectadas + ↓ +3. DELEGAR A REQUIREMENTS-ANALYST (si hay ambiguedad) + ↓ +4. DELEGAR A ARCHITECTURE-ANALYST (diseño) + ↓ +5. DELEGAR A DEVENV (si necesita puertos) + ↓ +6. DELEGAR A DATABASE → BACKEND → FRONTEND + ↓ +7. DELEGAR A CODE-REVIEWER + ↓ +8. VALIDACION FINAL +``` + +--- + +## Archivos con Referencias a Eliminar + +### Categoria 1: Referencias a ODOO (21 referencias) + +| Archivo | Lineas | Tipo de Referencia | +|---------|--------|-------------------| +| `PROJECT-STATUS.md` | 19, 206, 264 | Metricas de alineacion | +| `database/init/07-notifications-schema.sql` | 5 | Comentario patron | +| `orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md` | 36 | Directiva referencia | +| `docs/90-transversal/PLAN-RESOLUCION-GAPS.md` | 36 | Solucion referencia | +| `docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md` | Multiples (25+) | Analisis completo | + +### Categoria 2: Referencias a ERP-CORE (65+ referencias) + +**Archivos criticos (mayor cantidad):** + +| Archivo | Lineas Aprox | Acciones | +|---------|--------------|----------| +| `database/HERENCIA-ERP-CORE.md` | 20+ | Reescribir completo | +| `orchestration/00-guidelines/HERENCIA-ERP-CORE.md` | 15+ | Reescribir completo | +| `orchestration/00-guidelines/HERENCIA-SPECS-ERP-CORE.md` | 10+ | Reescribir completo | +| `orchestration/00-guidelines/CONTEXTO-PROYECTO.md` | 5 | Editar referencias | +| `orchestration/inventarios/MASTER_INVENTORY.yml` | 8 | Editar referencias | +| `orchestration/inventarios/DATABASE_INVENTORY.yml` | 8 | Editar referencias | +| `docs/00-vision-general/VISION.md` | 10+ | Editar referencias | +| `docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md` | 20+ | Mover a archivo historico | +| `README.md` | 3 | Editar descripcion | + +**Archivos SQL con comentarios:** + +| Archivo | Lineas | Accion | +|---------|--------|--------| +| `database/init/01-create-schemas.sql` | 7 | Eliminar comentario | +| `database/init/03-service-management-tables.sql` | 5, 14 | Eliminar comentarios | +| `database/init/04-parts-management-tables.sql` | 5 | Eliminar comentario | +| `database/init/09-purchasing-schema.sql` | 6 | Eliminar comentario | + +### Categoria 3: Referencias a MGN-* (62 referencias) + +Modulos genéricos referenciados que deben eliminarse: + +| Modulo | Descripcion Original | Reemplazo MMD | +|--------|---------------------|---------------| +| MGN-001 | Auth | MMD-001 Fundamentos | +| MGN-002 | Users | MMD-001 Fundamentos | +| MGN-003 | Roles | MMD-001 Fundamentos | +| MGN-004 | Tenants | MMD-001 Fundamentos | +| MGN-005 | Catalogs | MMD-001 + MMD-004 | +| MGN-006 | Purchasing | purchasing schema | +| MGN-007 | Sales | MMD-006 Cotizaciones | +| MGN-011 | Inventory | MMD-004 Inventario | +| MGN-013 | Portal | Fase 2 | + +**Archivos con mas referencias MGN:** + +- `docs/00-vision-general/VISION.md` (10+ referencias) +- `docs/08-epicas/README.md` (7 referencias) +- `orchestration/00-guidelines/HERENCIA-ERP-CORE.md` (10+ referencias) +- `docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md` (20+ referencias) + +### Categoria 4: Rutas Absolutas Incorrectas (16 referencias) + +Rutas que usan `/home/isem/workspace/` en lugar de `/home/adrian/Documentos/workspace/`: + +| Archivo | Ruta Incorrecta | +|---------|-----------------| +| `orchestration/environment/PROJECT-ENV-CONFIG.yml` | `/home/isem/workspace/...` | +| `orchestration/00-guidelines/CONTEXTO-PROYECTO.md` | `/home/isem/workspace/...` | +| `orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md` | `/home/isem/workspace/...` | +| `orchestration/prompts/PROMPT-MMD-BACKEND-AGENT.md` | `/home/isem/workspace/...` | +| `orchestration/inventarios/MASTER_INVENTORY.yml` | `/home/isem/workspace/...` | +| `orchestration/00-guidelines/HERENCIA-SIMCO.md` | `/home/isem/workspace/...` | + +--- + +## Plan de Limpieza + +### Fase 1: Eliminar Archivos de Analisis Historico + +Estos archivos fueron utiles para el analisis pero no deben permanecer: + +``` +MOVER A: docs/99-historico/ (o eliminar) +- docs/90-transversal/ANALISIS-ARQUITECTONICO-IMPLEMENTACION.md +- docs/90-transversal/PLAN-RESOLUCION-GAPS.md (parcial) +``` + +### Fase 2: Reescribir Archivos de Herencia + +``` +REESCRIBIR COMPLETAMENTE: +- database/HERENCIA-ERP-CORE.md → ARQUITECTURA-DATABASE.md +- orchestration/00-guidelines/HERENCIA-ERP-CORE.md → ELIMINAR +- orchestration/00-guidelines/HERENCIA-SPECS-ERP-CORE.md → ELIMINAR +- orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md → DIRECTIVAS-PROYECTO.md +``` + +### Fase 3: Editar Referencias en Documentacion + +``` +EDITAR: +- README.md: Eliminar "Extiende erp-core" +- PROJECT-STATUS.md: Eliminar metricas Odoo/erp-core +- docs/00-vision-general/VISION.md: Reescribir seccion herencia +- docs/08-epicas/README.md: Eliminar tabla MGN +- docs/02-definicion-modulos/*/README.md: Eliminar "Extiende MGN-*" +``` + +### Fase 4: Limpiar Comentarios SQL + +```sql +-- ELIMINAR comentarios como: +-- "NOTA: Los schemas auth, core, inventory se heredan de erp-core" +-- "Referencia a auth.tenants de erp-core" +-- "Implementa patron mail.thread de Odoo adaptado" +``` + +### Fase 5: Corregir Rutas Absolutas + +```yaml +# CAMBIAR de: +/home/isem/workspace/projects/erp-suite/apps/verticales/mecanicas-diesel + +# A (rutas relativas): +./ (o eliminar rutas absolutas) +``` + +### Fase 6: Actualizar Inventarios + +``` +EDITAR: +- orchestration/inventarios/MASTER_INVENTORY.yml +- orchestration/inventarios/DATABASE_INVENTORY.yml +- orchestration/inventarios/BACKEND_INVENTORY.yml +- orchestration/inventarios/FRONTEND_INVENTORY.yml +``` + +--- + +## Configuracion de Puerto Compartido: PostgreSQL + +El unico recurso compartido entre proyectos del workspace es PostgreSQL: + +```yaml +PostgreSQL: + puerto: 5432 + compartido: true + aislamiento: + - Cada proyecto tiene su propia BASE DE DATOS + - Cada proyecto tiene sus propios USUARIOS + - NO comparten schemas ni datos + - Multi-tenancy interno por tenant_id + +Ejemplo: + proyecto_1: + database: mecanicas_diesel_db + user: mecanicas_user + proyecto_2: + database: construccion_db + user: construccion_user +``` + +**Protocolo DEVENV:** +- Puerto 5432 es el unico compartido (PostgreSQL server) +- Todos los demas puertos (backend, frontend, etc.) son UNICOS por proyecto +- SIEMPRE consultar DEVENV antes de asignar nuevos puertos + +--- + +## Proximos Pasos + +1. **[PENDIENTE]** Ejecutar limpieza de referencias +2. **[PENDIENTE]** Validar que el proyecto sea completamente standalone +3. **[PENDIENTE]** Actualizar PROJECT-STATUS.md despues de limpieza +4. **[LISTO]** Iniciar desarrollo con TECH-LEADER + +--- + +## Notas Importantes + +### El Proyecto ES Independiente + +Aunque hay muchas referencias a erp-core y Odoo en la documentacion, el codigo DDL y la estructura del proyecto **YA SON INDEPENDIENTES**: + +- Los schemas son propios (`service_management`, `parts_management`, etc.) +- No hay FKs reales a erp-core +- Las funciones RLS son propias +- Los seeds son propios + +Las referencias son **documentales** (para trazabilidad del analisis), no **funcionales**. + +### Recomendacion + +Antes de limpiar, considerar si vale la pena mantener un archivo `docs/99-historico/ORIGEN-PATRONES.md` que documente de donde vinieron los patrones sin que sea parte activa del proyecto. + +--- + +**Documento creado por:** Architecture-Analyst +**Fecha:** 2025-12-12 +**Para uso de:** TECH-LEADER al iniciar desarrollo diff --git a/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/PLAN-RESOLUCION-GAPS.md b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/PLAN-RESOLUCION-GAPS.md new file mode 100644 index 0000000..e007e2d --- /dev/null +++ b/projects/erp-suite/apps/verticales/mecanicas-diesel/docs/90-transversal/PLAN-RESOLUCION-GAPS.md @@ -0,0 +1,663 @@ +# Plan de Resolucion de GAPs - Mecanicas Diesel + +**Fecha:** 2025-12-12 +**Proyecto:** mecanicas-diesel +**Estado:** Pre-desarrollo +**Responsable:** Architecture-Analyst + +--- + +## Resumen Ejecutivo + +Este documento detalla el plan para resolver los 12 GAPs identificados en el analisis arquitectonico antes de iniciar el desarrollo de backend/frontend del proyecto mecanicas-diesel. + +### Clasificacion de GAPs + +| Criticidad | Cantidad | Descripcion | +|------------|----------|-------------| +| **CRITICA (P0)** | 3 | Bloquean desarrollo, deben resolverse primero | +| **ALTA (P1)** | 5 | Importantes para MVP completo | +| **MEDIA (P2)** | 3 | Mejoras significativas | +| **BAJA (P3)** | 1 | Opcionales/simplificadas | + +--- + +## GAPs Criticos (P0) - Semana 1 + +### GAP-01: Sistema de Tracking de Cambios (mail.thread) + +**Problema:** No existe sistema automatico de auditoria/tracking de cambios en documentos (ordenes, cotizaciones, diagnosticos). + +**Impacto:** +- Pierde historial de modificaciones +- No hay trazabilidad de quien cambio que +- No cumple con auditorias + +**Solucion:** Implementar patron mail.thread de Odoo adaptado + +**SPEC de Referencia:** `erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-MAIL-THREAD-TRACKING.md` + +**Acciones:** +1. Crear schema `notifications` en mecanicas-diesel +2. Crear tablas: + - `notifications.messages` - Mensajes y tracking + - `notifications.message_subtypes` - Tipos de mensaje + - `notifications.tracking_values` - Valores trackeados +3. Implementar decorator `@Tracked` para campos +4. Agregar tracking a tablas criticas: + - `service_management.service_orders` + - `service_management.quotes` + - `service_management.diagnostics` + +**DDL Requerido:** +```sql +-- Schema de notificaciones +CREATE SCHEMA IF NOT EXISTS notifications; + +-- Tabla de mensajes (chatter) +CREATE TABLE notifications.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + res_model VARCHAR(100) NOT NULL, + res_id UUID NOT NULL, + message_type VARCHAR(20) NOT NULL DEFAULT 'notification', + subtype_code VARCHAR(50), + author_id UUID, + subject VARCHAR(500), + body TEXT, + tracking_values JSONB DEFAULT '[]', + is_internal BOOLEAN NOT NULL DEFAULT false, + parent_id UUID REFERENCES notifications.messages(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_message_type CHECK (message_type IN ('comment', 'notification', 'note')) +); + +-- Indices +CREATE INDEX idx_messages_resource ON notifications.messages(res_model, res_id); +CREATE INDEX idx_messages_tenant ON notifications.messages(tenant_id); +CREATE INDEX idx_messages_created ON notifications.messages(created_at DESC); +``` + +**Esfuerzo:** 8 horas +**Entregable:** DDL + Documentacion + +--- + +### GAP-02: Sistema de Followers/Suscriptores + +**Problema:** No hay manera de suscribirse a documentos para recibir notificaciones automaticas. + +**Impacto:** +- Usuarios no se enteran de cambios importantes +- Comunicacion manual requerida +- Pierde eficiencia operativa + +**Solucion:** Implementar sistema de followers + +**SPEC de Referencia:** `SPEC-MAIL-THREAD-TRACKING.md` (seccion Followers) + +**Acciones:** +1. Crear tabla `notifications.followers` +2. Crear tabla `notifications.follower_subtypes` +3. Implementar auto-suscripcion: + - Mecanico asignado sigue su orden + - Cliente sigue sus cotizaciones + - Jefe de taller sigue ordenes de su bahia + +**DDL Requerido:** +```sql +-- Seguidores de documentos +CREATE TABLE notifications.followers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + res_model VARCHAR(100) NOT NULL, + res_id UUID NOT NULL, + partner_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, res_model, res_id, partner_id) +); + +-- Suscripciones a tipos de mensaje +CREATE TABLE notifications.follower_subtypes ( + follower_id UUID NOT NULL REFERENCES notifications.followers(id) ON DELETE CASCADE, + subtype_code VARCHAR(50) NOT NULL, + PRIMARY KEY (follower_id, subtype_code) +); + +CREATE INDEX idx_followers_resource ON notifications.followers(res_model, res_id); +CREATE INDEX idx_followers_partner ON notifications.followers(partner_id); +``` + +**Esfuerzo:** 4 horas +**Entregable:** DDL + Documentacion + +--- + +### GAP-03: Actividades Programadas + +**Problema:** No hay sistema de recordatorios/actividades asociadas a documentos. + +**Impacto:** +- No hay seguimiento de llamadas pendientes +- No hay recordatorios de mantenimientos +- Clientes olvidados + +**Solucion:** Implementar sistema de actividades (mail.activity) + +**SPEC de Referencia:** `SPEC-MAIL-THREAD-TRACKING.md` (seccion Activities) + +**Acciones:** +1. Crear tabla `notifications.activities` +2. Crear tabla `notifications.activity_types` +3. Configurar tipos predeterminados: + - `call` - Llamar al cliente + - `meeting` - Cita de entrega + - `todo` - Tarea pendiente + - `reminder` - Recordatorio de mantenimiento + +**DDL Requerido:** +```sql +-- Tipos de actividad +CREATE TABLE notifications.activity_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + icon VARCHAR(50) DEFAULT 'fa-tasks', + default_days INTEGER DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Actividades programadas +CREATE TABLE notifications.activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + res_model VARCHAR(100) NOT NULL, + res_id UUID NOT NULL, + activity_type_id UUID NOT NULL REFERENCES notifications.activity_types(id), + user_id UUID NOT NULL, + date_deadline DATE NOT NULL, + summary VARCHAR(500), + note TEXT, + state VARCHAR(20) NOT NULL DEFAULT 'planned', + date_done TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + CONSTRAINT chk_activity_state CHECK (state IN ('planned', 'today', 'overdue', 'done', 'canceled')) +); + +-- Seed de tipos predeterminados +INSERT INTO notifications.activity_types (code, name, icon, default_days) VALUES +('call', 'Llamar cliente', 'fa-phone', 0), +('meeting', 'Cita de entrega', 'fa-calendar', 0), +('todo', 'Tarea pendiente', 'fa-tasks', 1), +('reminder', 'Recordatorio mantenimiento', 'fa-bell', 30), +('followup', 'Seguimiento cotizacion', 'fa-envelope', 3); + +CREATE INDEX idx_activities_resource ON notifications.activities(res_model, res_id); +CREATE INDEX idx_activities_user ON notifications.activities(user_id); +CREATE INDEX idx_activities_deadline ON notifications.activities(date_deadline); +CREATE INDEX idx_activities_state ON notifications.activities(state) WHERE state NOT IN ('done', 'canceled'); +``` + +**Esfuerzo:** 4 horas +**Entregable:** DDL + Documentacion + Seed + +--- + +## GAPs Alta Prioridad (P1) - Semana 2-3 + +### GAP-04: Facturacion Integrada (MMD-007) + +**Problema:** No hay modulo de facturacion, no se generan asientos contables. + +**Impacto:** +- No hay CFDI +- Proceso de facturacion manual +- Sin integracion contable + +**Solucion:** Documentar e implementar MMD-007 + +**SPEC de Referencia:** +- `SPEC-FIRMA-ELECTRONICA-NOM151.md` (para CFDI) +- `erp-core/docs/02-definicion-modulos/ALCANCE-POR-MODULO.md` (MGN-004) + +**Acciones:** +1. Crear documentacion de EPIC-MMD-007-facturacion.md +2. Crear documentacion de modulo MMD-007 +3. Definir historias de usuario (8-10 US) +4. Disenar schema `billing` +5. Integrar con PAC para timbrado + +**Fases:** +- Fase 2a: Pre-factura desde orden de servicio +- Fase 2b: Timbrado CFDI con PAC +- Fase 2c: Reportes de facturacion + +**Esfuerzo:** 40 horas (distribuido en Fase 2) +**Entregable:** Documentacion completa + DDL + +--- + +### GAP-05: Contabilidad Analitica + +**Problema:** No hay P&L por orden de servicio, no se puede medir rentabilidad. + +**Impacto:** +- No se sabe cuanto gana o pierde por orden +- No hay control de costos por servicio +- Decisiones sin datos financieros + +**Solucion:** Implementar cuentas analiticas simplificadas + +**SPEC de Referencia:** `SPEC-CONTABILIDAD-ANALITICA-MULTIDIMENSIONAL.md` + +**Acciones:** +1. Agregar `analytic_account_id` a `service_orders` +2. Crear tabla `analytics.accounts` simplificada +3. Crear tabla `analytics.lines` para costos/ingresos +4. Generar lineas automaticas al: + - Usar refacciones (costo) + - Facturar (ingreso) + - Registrar mano de obra (costo) + +**DDL Requerido:** +```sql +CREATE SCHEMA IF NOT EXISTS analytics; + +CREATE TABLE analytics.accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + account_type VARCHAR(20) NOT NULL DEFAULT 'service_order', + service_order_id UUID, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, code) +); + +CREATE TABLE analytics.lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + account_id UUID NOT NULL REFERENCES analytics.accounts(id), + date DATE NOT NULL, + name VARCHAR(256), + amount DECIMAL(20,6) NOT NULL, + unit_amount DECIMAL(20,6), + ref VARCHAR(100), + source_model VARCHAR(100), + source_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_analytics_lines_account ON analytics.lines(account_id); +CREATE INDEX idx_analytics_lines_date ON analytics.lines(date); +``` + +**Esfuerzo:** 16 horas +**Entregable:** DDL + Documentacion + +--- + +### GAP-06: Portal de Clientes + +**Problema:** Cliente no puede ver el avance de su vehiculo en linea. + +**Impacto:** +- Llamadas constantes preguntando estado +- Menor satisfaccion del cliente +- Proceso ineficiente + +**Solucion:** Implementar portal basico para clientes + +**SPEC de Referencia:** `ALCANCE-POR-MODULO.md` (MGN-013) + +**Acciones:** +1. Documentar MMD-Portal en Fase 2 +2. Crear rol `portal_cliente` +3. Implementar vistas de solo lectura: + - Mis vehiculos + - Mis ordenes de servicio + - Estado actual + - Cotizaciones pendientes de aprobar +4. Implementar aprobacion online de cotizaciones + +**Esfuerzo:** 32 horas (Fase 2) +**Entregable:** Documentacion + DDL permisos + +--- + +### GAP-09: Compras con RFQ Completo + +**Problema:** Solo hay recepciones, no hay ordenes de compra ni solicitudes de cotizacion. + +**Impacto:** +- No hay control de compras +- No hay historial de proveedores +- No hay aprobaciones de compra + +**Solucion:** Implementar modulo de compras simplificado + +**SPEC de Referencia:** `ALCANCE-POR-MODULO.md` (MGN-006) + +**Acciones:** +1. Crear schema `purchasing` +2. Crear tablas: + - `purchasing.purchase_orders` + - `purchasing.purchase_order_lines` + - `purchasing.suppliers` (o usar partners existentes) +3. Integrar con recepciones de inventario + +**DDL Requerido:** +```sql +CREATE SCHEMA IF NOT EXISTS purchasing; + +CREATE TABLE purchasing.purchase_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + order_number VARCHAR(50) NOT NULL, + supplier_id UUID NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'draft', + order_date DATE NOT NULL DEFAULT CURRENT_DATE, + expected_date DATE, + subtotal DECIMAL(20,6) NOT NULL DEFAULT 0, + tax_amount DECIMAL(20,6) NOT NULL DEFAULT 0, + total DECIMAL(20,6) NOT NULL DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + CONSTRAINT chk_po_status CHECK (status IN ('draft', 'sent', 'confirmed', 'received', 'cancelled')) +); + +CREATE TABLE purchasing.purchase_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + purchase_order_id UUID NOT NULL REFERENCES purchasing.purchase_orders(id) ON DELETE CASCADE, + part_id UUID NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + unit_price DECIMAL(20,6) NOT NULL, + subtotal DECIMAL(20,6) NOT NULL, + received_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_po_tenant ON purchasing.purchase_orders(tenant_id); +CREATE INDEX idx_po_supplier ON purchasing.purchase_orders(supplier_id); +CREATE INDEX idx_po_status ON purchasing.purchase_orders(status); +``` + +**Esfuerzo:** 24 horas +**Entregable:** DDL + Documentacion + +--- + +### GAP-10: Reporte de Garantias + +**Problema:** No hay tracking formal de garantias de refacciones usadas. + +**Impacto:** +- No se sabe cuales piezas estan en garantia +- Perdida de dinero por no reclamar garantias +- Sin historial para cliente + +**Solucion:** Agregar tracking de garantias + +**Acciones:** +1. Agregar campos de garantia a `parts_management.parts`: + - `warranty_months` + - `warranty_policy` +2. Crear tabla `parts_management.warranty_claims` +3. Crear vista de piezas en garantia + +**DDL Requerido:** +```sql +-- Ajuste a parts (si no existe) +ALTER TABLE parts_management.parts +ADD COLUMN IF NOT EXISTS warranty_months INTEGER DEFAULT 0; + +-- Claims de garantia +CREATE TABLE parts_management.warranty_claims ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + part_id UUID NOT NULL, + service_order_id UUID, + serial_number VARCHAR(100), + installation_date DATE NOT NULL, + expiration_date DATE NOT NULL, + claim_date DATE, + claim_status VARCHAR(20) DEFAULT 'active', + claim_notes TEXT, + resolution TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_warranty_status CHECK (claim_status IN ('active', 'claimed', 'approved', 'rejected', 'expired')) +); + +-- Vista de garantias activas +CREATE VIEW parts_management.v_active_warranties AS +SELECT + wc.*, + p.sku, + p.name as part_name, + so.order_number, + c.name as customer_name +FROM parts_management.warranty_claims wc +JOIN parts_management.parts p ON p.id = wc.part_id +LEFT JOIN service_management.service_orders so ON so.id = wc.service_order_id +LEFT JOIN workshop_core.customers c ON c.id = so.customer_id +WHERE wc.claim_status = 'active' + AND wc.expiration_date >= CURRENT_DATE; +``` + +**Esfuerzo:** 8 horas +**Entregable:** DDL + Vista + +--- + +## GAPs Media Prioridad (P2) - Semana 4+ + +### GAP-07: Integracion Calendario + +**Problema:** No hay agendamiento de citas de servicio. + +**Impacto:** +- No se pueden programar citas +- Overbooking de bahias +- Sin vista de calendario + +**Solucion:** Integrar con sistema de calendario (opcional) + +**SPEC de Referencia:** `SPEC-INTEGRACION-CALENDAR.md` + +**Acciones:** +1. Evaluar necesidad real del taller +2. Si se requiere: + - Crear tabla `scheduling.appointments` + - Integrar con bahias de trabajo + - Vista de calendario por bahia + +**Esfuerzo:** 8 horas (si se requiere) +**Entregable:** Evaluacion + DDL opcional + +--- + +### GAP-08: Pricing Rules Avanzado + +**Problema:** No hay descuentos escalonados ni reglas de precios complejas. + +**Impacto:** +- Precios manuales +- Sin descuentos por volumen +- Sin promociones + +**Solucion:** Implementar pricing basico + +**SPEC de Referencia:** `SPEC-PRICING-RULES.md` + +**Acciones:** +1. Evaluar necesidad (talleres normalmente tienen precios fijos) +2. Si se requiere: + - Crear tabla `pricing.pricelists` + - Crear tabla `pricing.pricelist_items` + - Integrar con cotizaciones + +**Esfuerzo:** 16 horas (si se requiere) +**Entregable:** Evaluacion + DDL opcional + +--- + +### GAP-12: Firma Electronica + +**Problema:** Aprobacion de cotizaciones sin firma legal. + +**Impacto:** +- Sin respaldo legal +- Disputas de aprobacion +- Proceso informal + +**Solucion:** Implementar firma basica + +**SPEC de Referencia:** `SPEC-FIRMA-ELECTRONICA-NOM151.md` + +**Acciones:** +1. Para MVP: Firma canvas simple (HTML5) +2. Agregar campos a cotizaciones: + - `signature_data` (base64) + - `signed_at` + - `signed_by_ip` +3. Para Fase 2+: Evaluar NOM-151 completa + +**DDL Requerido:** +```sql +-- Agregar campos de firma a cotizaciones +ALTER TABLE service_management.quotes +ADD COLUMN IF NOT EXISTS signature_data TEXT, +ADD COLUMN IF NOT EXISTS signed_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS signed_by_ip VARCHAR(45), +ADD COLUMN IF NOT EXISTS signed_by_name VARCHAR(256); +``` + +**Esfuerzo:** 8 horas (firma basica) +**Entregable:** DDL + Documentacion + +--- + +## GAPs Baja Prioridad (P3) - Post-MVP + +### GAP-11: Contratos de Empleados + +**Problema:** Empleados (mecanicos) sin gestion de contratos formal. + +**Impacto:** +- Sin historial laboral +- Sin control de documentos +- Riesgo legal + +**Solucion:** Simplificado para taller + +**Decision:** Para MVP, mantener simplificado con campos basicos en `users`. Implementar HR completo en Fase 3 si el taller lo requiere. + +**Esfuerzo:** 0 horas (mantener simplificado) +**Entregable:** N/A + +--- + +## Cronograma de Implementacion + +``` +SEMANA 1 (Gaps Criticos P0) +├── Dia 1-2: GAP-01 Sistema de tracking (8h) +├── Dia 3: GAP-02 Followers (4h) +├── Dia 4: GAP-03 Actividades (4h) +└── Dia 5: Pruebas y documentacion + +SEMANA 2 (Gaps P1 - Parte 1) +├── Dia 1-2: GAP-05 Contabilidad analitica (16h) +└── Dia 3-5: GAP-10 Garantias (8h) + Documentacion GAP-04 + +SEMANA 3 (Gaps P1 - Parte 2) +├── Dia 1-3: GAP-09 Compras basico (24h) +└── Dia 4-5: GAP-12 Firma basica (8h) + +SEMANA 4+ (Evaluacion Gaps P2) +├── GAP-07 Calendario - Evaluar necesidad +├── GAP-08 Pricing - Evaluar necesidad +└── Documentacion GAP-04/GAP-06 para Fase 2 +``` + +--- + +## Entregables por Semana + +### Semana 1 +- [ ] `database/init/07-notifications-schema.sql` +- [ ] `docs/03-modelo-datos/SCHEMA-NOTIFICATIONS.md` +- [ ] Actualizacion de `PROJECT-STATUS.md` + +### Semana 2 +- [ ] `database/init/08-analytics-schema.sql` +- [ ] `docs/03-modelo-datos/SCHEMA-ANALYTICS.md` +- [ ] `docs/02-definicion-modulos/MMD-007-facturacion/README.md` (estructura) + +### Semana 3 +- [ ] `database/init/09-purchasing-schema.sql` +- [ ] `docs/03-modelo-datos/SCHEMA-PURCHASING.md` +- [ ] Actualizacion DDL cotizaciones (firma) + +### Semana 4 +- [ ] Evaluacion y decision de Gaps P2 +- [ ] Documentacion completa para desarrollo + +--- + +## Metricas de Exito + +| Metrica | Objetivo | Validacion | +|---------|----------|------------| +| Gaps P0 resueltos | 3/3 | DDL ejecutable | +| Gaps P1 documentados | 5/5 | Documentacion completa | +| DDL sin errores | 100% | Scripts de validacion | +| Cobertura documentacion | 100% | Review | + +--- + +## Riesgos y Mitigaciones + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Complejidad tracking | Media | Alto | Usar SPEC existente de erp-core | +| Integracion con existente | Baja | Medio | Probar incrementalmente | +| Cambios de alcance | Media | Medio | Documentar decisiones | + +--- + +## Aprobaciones + +- [ ] **Product Owner:** Aprobacion de priorizacion +- [ ] **Tech Lead:** Revision tecnica de DDL +- [ ] **QA:** Plan de validacion + +--- + +## Estado de Implementacion + +| GAP | DDL Creado | Fecha | +|-----|------------|-------| +| GAP-01 | 07-notifications-schema.sql | 2025-12-12 | +| GAP-02 | 07-notifications-schema.sql | 2025-12-12 | +| GAP-03 | 07-notifications-schema.sql | 2025-12-12 | +| GAP-04 | Fase 2 (documentado) | - | +| GAP-05 | 08-analytics-schema.sql | 2025-12-12 | +| GAP-06 | Fase 2 (documentado) | - | +| GAP-07 | Evaluado - Opcional | - | +| GAP-08 | Evaluado - Opcional | - | +| GAP-09 | 09-purchasing-schema.sql | 2025-12-12 | +| GAP-10 | 10-warranty-claims.sql | 2025-12-12 | +| GAP-11 | Simplificado (Post-MVP) | - | +| GAP-12 | 11-quote-signature.sql | 2025-12-12 | + +--- + +**Documento creado por:** Architecture-Analyst +**Fecha:** 2025-12-12 +**Version:** 1.1.0 +**Estado:** IMPLEMENTADO - DDL creados