changes on project erp-suite
Some checks are pending
CI Pipeline / changes (push) Waiting to run
CI Pipeline / core (push) Blocked by required conditions
CI Pipeline / trading-backend (push) Blocked by required conditions
CI Pipeline / trading-data-service (push) Blocked by required conditions
CI Pipeline / trading-frontend (push) Blocked by required conditions
CI Pipeline / erp-core (push) Blocked by required conditions
CI Pipeline / erp-mecanicas (push) Blocked by required conditions
CI Pipeline / gamilit-backend (push) Blocked by required conditions
CI Pipeline / gamilit-frontend (push) Blocked by required conditions

This commit is contained in:
Adrian Flores Cortes 2025-12-12 07:51:55 -06:00
parent 48e7235e79
commit 7a0e02cde8
90 changed files with 17991 additions and 482 deletions

View File

@ -7,6 +7,7 @@ import { config } from './config/index.js';
import { logger } from './shared/utils/logger.js'; import { logger } from './shared/utils/logger.js';
import { AppError, ApiResponse } from './shared/types/index.js'; import { AppError, ApiResponse } from './shared/types/index.js';
import authRoutes from './modules/auth/auth.routes.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 usersRoutes from './modules/users/users.routes.js';
import companiesRoutes from './modules/companies/companies.routes.js'; import companiesRoutes from './modules/companies/companies.routes.js';
import coreRoutes from './modules/core/core.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 systemRoutes from './modules/system/system.routes.js';
import crmRoutes from './modules/crm/crm.routes.js'; import crmRoutes from './modules/crm/crm.routes.js';
import hrRoutes from './modules/hr/hr.routes.js'; import hrRoutes from './modules/hr/hr.routes.js';
import reportsRoutes from './modules/reports/reports.routes.js';
const app: Application = express(); const app: Application = express();
@ -48,6 +50,7 @@ app.get('/health', (_req: Request, res: Response) => {
// API routes // API routes
const apiPrefix = config.apiPrefix; const apiPrefix = config.apiPrefix;
app.use(`${apiPrefix}/auth`, authRoutes); app.use(`${apiPrefix}/auth`, authRoutes);
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
app.use(`${apiPrefix}/users`, usersRoutes); app.use(`${apiPrefix}/users`, usersRoutes);
app.use(`${apiPrefix}/companies`, companiesRoutes); app.use(`${apiPrefix}/companies`, companiesRoutes);
app.use(`${apiPrefix}/core`, coreRoutes); app.use(`${apiPrefix}/core`, coreRoutes);
@ -60,6 +63,7 @@ app.use(`${apiPrefix}/projects`, projectsRoutes);
app.use(`${apiPrefix}/system`, systemRoutes); app.use(`${apiPrefix}/system`, systemRoutes);
app.use(`${apiPrefix}/crm`, crmRoutes); app.use(`${apiPrefix}/crm`, crmRoutes);
app.use(`${apiPrefix}/hr`, hrRoutes); app.use(`${apiPrefix}/hr`, hrRoutes);
app.use(`${apiPrefix}/reports`, reportsRoutes);
// 404 handler // 404 handler
app.use((_req: Request, res: Response) => { app.use((_req: Request, res: Response) => {

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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;

View File

@ -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<ApiKey, 'key_hash'>;
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<string> {
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<boolean> {
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<ApiKeyWithPlainKey> {
// 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<ApiKey>(
`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<Omit<ApiKey, 'key_hash'>[]> {
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<ApiKey>(
`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<Omit<ApiKey, 'key_hash'> | null> {
const apiKey = await queryOne<ApiKey>(
`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<Omit<ApiKey, 'key_hash'>> {
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<ApiKey>(
`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<void> {
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<void> {
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<ApiKeyValidationResult> {
// 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<ApiKey>(
`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<ApiKeyWithPlainKey> {
const existing = await queryOne<ApiKey>(
'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<ApiKey>(
`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();

View File

@ -1,3 +1,8 @@
export * from './auth.service.js'; export * from './auth.service.js';
export * from './auth.controller.js'; export * from './auth.controller.js';
export { default as authRoutes } from './auth.routes.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';

View File

@ -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<string> {
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<void> {
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<string, { name: string; prefix: string; padding: number }> = {
[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<Sequence[]> {
return query<Sequence>(
`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<Sequence | null> {
return queryOne<Sequence>(
`SELECT * FROM core.sequences
WHERE code = $1 AND tenant_id = $2`,
[code, tenantId]
);
}
/**
* Create a new sequence
*/
async create(dto: CreateSequenceDto, tenantId: string): Promise<Sequence> {
// 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<Sequence>(
`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<Sequence> {
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<Sequence>(
`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<Sequence> {
const updated = await queryOne<Sequence>(
`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<string> {
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<void> {
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();

View File

@ -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<FiscalYear[]> {
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<FiscalYear>(sql, params);
}
async findYearById(id: string, tenantId: string): Promise<FiscalYear> {
const year = await queryOne<FiscalYear>(
`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<FiscalYear> {
// 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<FiscalYear>(
`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<FiscalPeriod[]> {
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<FiscalPeriod>(
`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<FiscalPeriod> {
const period = await queryOne<FiscalPeriod>(
`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<FiscalPeriod | null> {
return queryOne<FiscalPeriod>(
`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<FiscalPeriod> {
// 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<FiscalPeriod>(
`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<FiscalPeriod> {
// Verify period exists and belongs to tenant
await this.findPeriodById(periodId, tenantId);
// Use database function for atomic close with validations
const result = await queryOne<FiscalPeriod>(
`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<FiscalPeriod> {
// Verify period exists and belongs to tenant
await this.findPeriodById(periodId, tenantId);
// Use database function for atomic reopen with audit
const result = await queryOne<FiscalPeriod>(
`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<FiscalPeriod[]> {
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();

View File

@ -10,5 +10,7 @@ export {
export * from './pickings.service.js'; export * from './pickings.service.js';
export * from './lots.service.js'; export * from './lots.service.js';
export * from './adjustments.service.js'; export * from './adjustments.service.js';
export * from './valuation.service.js';
export * from './inventory.controller.js'; export * from './inventory.controller.js';
export * from './valuation.controller.js';
export { default as inventoryRoutes } from './inventory.routes.js'; export { default as inventoryRoutes } from './inventory.routes.js';

View File

@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { inventoryController } from './inventory.controller.js'; import { inventoryController } from './inventory.controller.js';
import { valuationController } from './valuation.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router(); const router = Router();
@ -149,4 +150,25 @@ router.delete('/adjustments/:id', requireRoles('admin', 'super_admin'), (req, re
inventoryController.deleteAdjustment(req, res, next) 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; export default router;

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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<StockValuationLayer> {
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<FifoConsumptionResult> {
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<ProductCostResult> {
// 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<ValuationSummary | null> {
const result = await queryOne<ValuationSummary>(
`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<StockValuationLayer[]> {
const whereClause = includeEmpty
? ''
: 'AND remaining_qty > 0';
return query<StockValuationLayer>(
`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<ValuationSummary[]> {
return query<ValuationSummary>(
`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<void> {
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<void> {
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();

View File

@ -1,3 +1,5 @@
export * from './partners.service.js'; export * from './partners.service.js';
export * from './partners.controller.js'; export * from './partners.controller.js';
export * from './ranking.service.js';
export * from './ranking.controller.js';
export { default as partnersRoutes } from './partners.routes.js'; export { default as partnersRoutes } from './partners.routes.js';

View File

@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { partnersController } from './partners.controller.js'; import { partnersController } from './partners.controller.js';
import { rankingController } from './ranking.controller.js';
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
const router = Router(); const router = Router();
@ -7,6 +8,56 @@ const router = Router();
// All routes require authentication // All routes require authentication
router.use(authenticate); 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 // Convenience endpoints for customers and suppliers
router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next)); router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next));
router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next)); router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next));

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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<RankingCalculationResult> {
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<PartnerRanking>(
`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<PartnerRanking | null> {
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<PartnerRanking>(sql, params);
}
/**
* Get top partners (customers or suppliers)
*/
async getTopPartners(
tenantId: string,
type: 'customers' | 'suppliers',
limit: number = 10
): Promise<TopPartner[]> {
const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank';
const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc';
return query<TopPartner>(
`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<PartnerRanking[]> {
return query<PartnerRanking>(
`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<TopPartner>(
`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();

View File

@ -0,0 +1,3 @@
export * from './reports.service.js';
export * from './reports.controller.js';
export { default as reportsRoutes } from './reports.routes.js';

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();

View File

@ -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;

View File

@ -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<string, any>;
columns_config: any[];
grouping_options: string[];
totals_config: Record<string, any>;
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<string, any>;
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<string, any> | null;
output_files: any[];
error_message: string | null;
error_details: Record<string, any> | 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<string, any>;
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<string, any>;
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<string, any>;
columns_config?: any[];
export_formats?: string[];
required_permissions?: string[];
}
export interface ExecuteReportDto {
definition_id: string;
parameters: Record<string, any>;
}
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<ReportDefinition>(
`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<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`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<ReportDefinition | null> {
return queryOne<ReportDefinition>(
`SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`,
[code, tenantId]
);
}
async createDefinition(
dto: CreateReportDefinitionDto,
tenantId: string,
userId: string
): Promise<ReportDefinition> {
const definition = await queryOne<ReportDefinition>(
`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<ReportExecution> {
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<ReportExecution>(
`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<string, any>,
tenantId: string
): Promise<void> {
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<string, any>,
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<string, any>,
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<string, any>): Record<string, any> {
if (!totalsConfig.show_totals || !totalsConfig.total_columns) {
return {};
}
const summary: Record<string, number> = {};
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<string, any>, schema: Record<string, any>): 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<ReportExecution> {
const execution = await queryOne<ReportExecution>(
`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<ReportExecution[]> {
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<ReportExecution>(sql, params);
}
// ==================== SCHEDULES ====================
async findAllSchedules(tenantId: string): Promise<ReportSchedule[]> {
return query<ReportSchedule>(
`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<string, any>;
company_id?: string;
timezone?: string;
delivery_method?: DeliveryMethod;
delivery_config?: Record<string, any>;
},
tenantId: string,
userId: string
): Promise<ReportSchedule> {
// Verificar que la definición existe
await this.findDefinitionById(data.definition_id, tenantId);
const schedule = await queryOne<ReportSchedule>(
`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<ReportSchedule> {
const schedule = await queryOne<ReportSchedule>(
`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<void> {
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<any[]> {
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<any[]> {
return query(
`SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`,
[tenantId, companyId, accountId, dateFrom, dateTo]
);
}
}
export const reportsService = new ReportsService();

View File

@ -1,6 +1,7 @@
import { query, queryOne, getClient } from '../../config/database.js'; import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
import { taxesService } from '../financial/taxes.service.js'; import { taxesService } from '../financial/taxes.service.js';
import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js';
export interface SalesOrderLine { export interface SalesOrderLine {
id: string; id: string;
@ -252,13 +253,8 @@ class OrdersService {
} }
async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> { async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise<SalesOrder> {
// Generate sequence number // Generate sequence number using atomic database function
const seqResult = await queryOne<{ next_num: number }>( const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId);
`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')}`;
const orderDate = dto.order_date || new Date().toISOString().split('T')[0]; const orderDate = dto.order_date || new Date().toISOString().split('T')[0];

View File

@ -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<string, { count: number; resetTime: number }>();
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);
}
};
}

View File

@ -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<string, FieldPermission>;
}
// Cache for field permissions per user/model
const permissionsCache = new Map<string, { permissions: ModelFieldPermissions; expires: number }>();
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<ModelFieldPermissions | null> {
// 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<T extends Record<string, any>>(
data: T,
permissions: ModelFieldPermissions | null
): Partial<T> {
// No permissions defined = return all fields
if (!permissions || permissions.fields.size === 0) {
return data;
}
const filtered: Record<string, any> = {};
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<T>;
}
/**
* Filter array of objects
*/
function filterReadFieldsArray<T extends Record<string, any>>(
data: T[],
permissions: ModelFieldPermissions | null
): Partial<T>[] {
return data.map(item => filterReadFields(item, permissions));
}
/**
* Validate write permissions for incoming data
*/
function validateWriteFields<T extends Record<string, any>>(
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<void> => {
// 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<void> => {
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<void> => {
// 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 };
}

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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

View File

@ -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

View File

@ -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 **Estado:** 🚧 En desarrollo
**Progreso:** 35% **Progreso:** 55%
**Última actualización:** 2025-12-08 **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<T>` - 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 | | Área | Implementado | Documentado | Estado |
|------|-------------|-------------|--------| |------|-------------|-------------|--------|
| **DDL/Schemas** | 3 schemas, 33 tablas | 7 schemas, 65 tablas | 50% | | **DDL/Schemas** | 7 schemas, 110 tablas | 7 schemas, 110 tablas | 100% |
| **Backend** | 4 módulos, 11 entidades | 18 módulos | 22% | | **Backend** | 7 módulos, 30 entidades | 18 módulos | 40% |
| **Frontend** | Estructura base | 18 módulos | 5% | | **Frontend** | Estructura base | 18 módulos | 5% |
| **Documentación** | - | 449 archivos MD | 100% | | **Documentación** | - | 449 archivos MD | 100% |
@ -23,16 +101,20 @@
### Schemas Implementados (DDL) ### Schemas Implementados (DDL)
| Schema | Tablas | ENUMs | Archivo DDL | | Schema | Tablas | ENUMs | Archivo DDL |
|--------|--------|-------|-------------| |--------|--------|-------|-------------|
| `construction` | 2 | - | `01-construction-schema-ddl.sql` | | `construction` | 24 | 7 | `01-construction-schema-ddl.sql` |
| `hr` | 3 | - | `02-hr-schema-ddl.sql` | | `hr` | 8 | - | `02-hr-schema-ddl.sql` |
| `hse` | 28 | 67 | `03-hse-schema-ddl.sql` | | `hse` | 58 | 67 | `03-hse-schema-ddl.sql` |
| **Total** | **33** | **67** | | | `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 ### DDL Completo
- `estimates` - Presupuestos y estimaciones (8 tablas documentadas) Todos los schemas han sido implementados con:
- `infonavit` - Integración INFONAVIT (8 tablas documentadas) - RLS (Row Level Security) para multi-tenancy
- `inventory-ext` - Extensión inventario (4 tablas documentadas) - Indices optimizados
- `purchase-ext` - Extensión compras (5 tablas documentadas) - Funciones auxiliares (ej: `calculate_estimate_totals`)
--- ---
@ -41,27 +123,58 @@
### Módulos con Código ### Módulos con Código
``` ```
backend/src/modules/ backend/src/modules/
├── construction/ ✅ Entidades + Services + Controllers ├── auth/ ✅ Autenticación JWT completa
│ ├── proyecto.entity.ts │ ├── services/auth.service.ts
│ └── fraccionamiento.entity.ts │ ├── middleware/auth.middleware.ts
├── hr/ ✅ Entidades básicas │ └── dto/auth.dto.ts
│ ├── employee.entity.ts ├── budgets/ ✅ Presupuestos (MAI-003)
│ ├── puesto.entity.ts │ ├── entities/concepto.entity.ts
│ └── employee-fraccionamiento.entity.ts │ ├── entities/presupuesto.entity.ts
├── hse/ ✅ Entidades básicas │ ├── entities/presupuesto-partida.entity.ts
│ ├── incidente.entity.ts │ ├── services/concepto.service.ts
│ ├── incidente-involucrado.entity.ts │ └── services/presupuesto.service.ts
│ ├── incidente-accion.entity.ts ├── progress/ ✅ Control de Obra (MAI-005)
│ └── capacitacion.entity.ts │ ├── entities/avance-obra.entity.ts
└── core/ ✅ Base multi-tenant │ ├── entities/foto-avance.entity.ts
├── user.entity.ts │ ├── entities/bitacora-obra.entity.ts
└── tenant.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 ### 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 - 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-001 | Fundamentos | - | ✅ | ✅ |
| MAI-002 | Proyectos y Estructura | ✅ | ✅ | ✅ | | MAI-002 | Proyectos y Estructura | ✅ | ✅ | ✅ |
| MAI-003 | Presupuestos y Costos | ⏳ | ❌ | ✅ | | MAI-003 | Presupuestos y Costos | ✅ | ✅ | ✅ |
| MAI-004 | Compras e Inventarios | ⏳ | ❌ | ✅ | | MAI-004 | Compras e Inventarios | ✅ | ⏳ | ✅ |
| MAI-005 | Control de Obra | ⏳ | ❌ | ✅ | | MAI-005 | Control de Obra | ✅ | ✅ | ✅ |
| MAI-006 | Reportes y Analytics | - | ❌ | ✅ | | MAI-006 | Reportes y Analytics | - | ❌ | ✅ |
| MAI-007 | RRHH y Asistencias | ✅ | ✅ | ✅ | | MAI-007 | RRHH y Asistencias | ✅ | ✅ | ✅ |
| MAI-008 | Estimaciones | ⏳ | ❌ | ✅ | | MAI-008 | Estimaciones | ✅ | ✅ | ✅ |
| MAI-009 | Calidad y Postventa | ⏳ | ❌ | ✅ | | MAI-009 | Calidad y Postventa | ✅ | ⏳ | ✅ |
| MAI-010 | CRM Derechohabientes | ⏳ | ❌ | ✅ | | MAI-010 | CRM Derechohabientes | ⏳ | ❌ | ✅ |
| MAI-011 | INFONAVIT | ⏳ | ❌ | ✅ | | MAI-011 | INFONAVIT | ✅ | ⏳ | ✅ |
| MAI-012 | Contratos | ⏳ | ❌ | ✅ | | MAI-012 | Contratos | ✅ | ⏳ | ✅ |
| MAI-013 | Administración | - | ❌ | ✅ | | MAI-013 | Administración | - | ❌ | ✅ |
| MAI-018 | Preconstrucción | ⏳ | ❌ | ✅ | | MAI-018 | Preconstrucción | ⏳ | ❌ | ✅ |
@ -97,21 +210,31 @@ backend/src/modules/
|--------|--------|:---:|:-------:|:----:| |--------|--------|:---:|:-------:|:----:|
| MAA-017 | Seguridad HSE | ✅ | ✅ | ✅ | | MAA-017 | Seguridad HSE | ✅ | ✅ | ✅ |
**Leyenda:** ✅ Implementado | ⏳ Pendiente | ❌ No iniciado | - No aplica **Leyenda:** ✅ Implementado | ⏳ En progreso | ❌ No iniciado | - No aplica
--- ---
## 🎯 PRÓXIMOS PASOS ## 🎯 PRÓXIMOS PASOS
### Inmediato ### Inmediato
1. Implementar DDL de `estimates` schema 1. ✅ ~~Implementar DDL de `estimates` schema~~ - COMPLETADO
2. Implementar DDL de `infonavit` schema 2. ✅ ~~Implementar DDL de `infonavit` schema~~ - COMPLETADO
3. Completar services/controllers de `hr` y `hse` 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 ### Corto Plazo
4. Implementar backend de MAI-003 (Presupuestos) 1. Crear Controllers REST para módulos nuevos
5. Implementar backend de MAI-005 (Control de Obra) 2. Implementar backend de MAI-009 (Calidad y Postventa)
6. Testing de módulos existentes 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` - **DDL:** `database/schemas/*.sql`
- **Backend:** `backend/src/modules/` - **Backend:** `backend/src/modules/`
- **Services:** `backend/src/shared/services/base.service.ts`
- **Auth:** `backend/src/modules/auth/`
- **Docs:** `docs/02-definicion-modulos/` - **Docs:** `docs/02-definicion-modulos/`
- **Inventario:** `orchestration/inventarios/MASTER_INVENTORY.yml` - **Inventario:** `orchestration/inventarios/MASTER_INVENTORY.yml`
- **Constants SSOT:** `backend/src/shared/constants/`
--- ---
@ -134,7 +260,10 @@ backend/src/modules/
| User Stories | 149 | | User Stories | 149 |
| Story Points | 692 | | Story Points | 692 |
| ADRs | 12 | | ADRs | 12 |
| Entidades TypeORM | 30 |
| Services Backend | 8 |
| Tablas DDL | 110 |
--- ---
**Última actualización:** 2025-12-08 **Última actualización:** 2025-12-12

View File

@ -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 | | Campo | Valor |
|-------|-------| |-------|-------|
| **Estado** | En desarrollo (35%) | | **Estado** | 🚧 En desarrollo (55%) |
| **Version** | 0.1.0 | | **Version** | 0.2.0 |
| **Base** | Extiende erp-core (61% reutilizacion) | | **Modulos** | 18 (14 MAI + 3 MAE + 1 MAA) |
| **Modulos** | 18 (14 Fase 1 + 3 Fase 2 + 1 Fase 3) | | **DDL Schemas** | 7 (110 tablas) |
| **RF** | 79 | | **Entidades Backend** | 30 |
| **ET** | 78 | | **Services Backend** | 8 |
| **US** | 139 |
| **ADRs** | 12 | ---
## 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/ construccion/
+-- backend/ # Extensiones backend especificas ├── backend/
| +-- src/ │ └── src/
| +-- server.ts │ ├── modules/
| +-- shared/database/ │ │ ├── auth/ # Autenticacion JWT
+-- frontend/ │ │ ├── budgets/ # MAI-003 Presupuestos
| +-- web/ # App web de gestion (React + Vite) │ │ ├── progress/ # MAI-005 Control de Obra
| +-- mobile/ # App movil para campo (React Native) │ │ ├── estimates/ # MAI-008 Estimaciones
+-- database/ # DDL y migrations especificos │ │ ├── construction/ # MAI-002 Proyectos
| +-- ddl/ │ │ ├── hr/ # MAI-007 RRHH
| +-- scripts/ │ │ ├── hse/ # MAA-017 HSE
+-- docs/ # Documentacion completa (407+ archivos) │ │ └── core/ # Entidades base
| +-- 00-overview/ # Vision general │ └── shared/
| +-- 01-analisis-referencias/ # Mapeo a erp-core │ ├── constants/ # SSOT (schemas, rutas, enums)
| +-- 02-definicion-modulos/ # 18 modulos MAI/MAE/MAA │ └── services/ # BaseService multi-tenant
| +-- 03-requerimientos/ # Indice consolidado RF (79) ├── frontend/
| +-- 04-modelado/ # Domain models + DDL │ ├── web/ # App web React
| +-- 05-user-stories/ # Indice consolidado US (139) │ └── mobile/ # App movil (futuro)
| +-- 06-frontend-specs/ # Especificaciones UI ├── database/
| +-- 06-test-plans/ # Planes de prueba │ └── schemas/ # DDL por schema
| +-- 07-devops/ # DevOps y deployment │ ├── 01-construction-schema-ddl.sql # 24 tablas
| +-- 08-epicas/ # Epicas consolidadas │ ├── 02-hr-schema-ddl.sql # 8 tablas
| +-- 90-transversal/ # Documentacion cruzada │ ├── 03-hse-schema-ddl.sql # 58 tablas
| +-- 97-adr/ # 12 ADRs │ ├── 04-estimates-schema-ddl.sql # 8 tablas
+-- orchestration/ # Sistema de agentes NEXUS │ ├── 05-infonavit-schema-ddl.sql # 8 tablas
+-- 00-guidelines/ │ ├── 06-inventory-ext-schema-ddl.sql # 4 tablas
+-- directivas/ │ └── 07-purchase-ext-schema-ddl.sql # 5 tablas
+-- prompts/ ├── docs/ # Documentacion completa
+-- trazas/ ├── devops/
+-- estados/ │ └── 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 | ```typescript
|--------|--------|---:|---:|--------| // Entidades
| MAI-001 | Fundamentos y Seguridad | 3 | 8 | Documentado | - Concepto // Catalogo jerarquico de conceptos
| MAI-002 | Proyectos y Estructura | 4 | 9 | Documentado | - Presupuesto // Presupuestos versionados
| MAI-003 | Presupuestos y Costos | 4 | 8 | Documentado | - PresupuestoPartida // Lineas con calculo automatico
| 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 |
### Fase 2: Enterprise (3 modulos, 210 SP) // Services
- ConceptoService // Arbol, busqueda
- PresupuestoService // CRUD, versionamiento, aprobacion
```
| Codigo | Modulo | RF | US | Estado | ### MAI-005: Control de Obra ✅
|--------|--------|---:|---:|--------|
| MAE-014 | Finanzas y Controlling | 5 | 11 | Documentado |
| MAE-015 | Activos y Maquinaria | 5 | 8 | Documentado |
| MAE-016 | Gestion Documental (DMS) | 5 | 7 | Documentado |
### 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 | // Services
|--------|--------|--------| - AvanceObraService // Workflow captura->revision->aprobacion
| MAA-017 | Seguridad HSE | Por documentar | - 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 | ### Autenticacion
|--------|-------------|
| `project_management` | Proyectos, desarrollos, fases, viviendas | ```http
| `financial_management` | Presupuestos, partidas, estimaciones | POST /api/v1/auth/login
| `purchasing_management` | Compras, proveedores, inventarios | POST /api/v1/auth/register
| `construction_management` | Avances, recursos, materiales | POST /api/v1/auth/refresh
| `quality_management` | Inspecciones, pruebas, no conformidades | POST /api/v1/auth/logout
| `infonavit_management` | Integracion INFONAVIT | POST /api/v1/auth/change-password
| `hr_management` | Personal, cuadrillas, asistencias | ```
| `crm_management` | Prospectos, derechohabientes |
| `contract_management` | Contratos, subcontratos | ### Presupuestos
| `assets_management` | Activos, maquinaria, mantenimiento |
| `documents_management` | DMS, versionado, workflows | ```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 | | Documento | Ubicacion |
|-----------|-----------| |-----------|-----------|
| **Indice principal** | `docs/README.md` | | Estado del Proyecto | `PROJECT-STATUS.md` |
| **Requerimientos (79 RF)** | `docs/03-requerimientos/README.md` | | Mapa de Base de Datos | `database/_MAP.md` |
| **User Stories (139 US)** | `docs/05-user-stories/README.md` | | Constantes SSOT | `backend/src/shared/constants/` |
| **Modulos (18)** | `docs/02-definicion-modulos/_MAP.md` | | Modulos (18) | `docs/02-definicion-modulos/` |
| **ADRs (12)** | `docs/97-adr/README.md` | | Requerimientos (87 RF) | `docs/03-requerimientos/` |
| **Contexto proyecto** | `orchestration/00-guidelines/CONTEXTO-PROYECTO.md` | | User Stories (149 US) | `docs/05-user-stories/` |
| **Proxima accion** | `orchestration/PROXIMA-ACCION.md` | | ADRs (12) | `docs/97-adr/` |
| **Schemas SQL** | `docs/04-modelado/database-design/schemas/` |
--- ---
## Reutilizacion de ERP Core ## Proximos Pasos
| Capa | Reutilizacion | 1. **Corto Plazo**
|------|---------------| - Controllers REST para modulos nuevos
| Infraestructura (Auth, RLS, RBAC) | 90% | - Backend MAI-009 (Calidad y Postventa)
| Backend (Patrones, Servicios) | 60-80% | - Backend MAI-011 (INFONAVIT)
| Frontend (UI, Hooks, Stores) | 50-70% | - Testing de modulos existentes
| Database (Schemas, Funciones) | 70% |
| **Total** | **61%** | 2. **Mediano Plazo**
- Frontend: Integracion con API
- Modulos de Presupuestos y Estimaciones
- Curva S y reportes de avance
--- ---
## Comandos Utiles ## Licencia
```bash UNLICENSED - Proyecto privado
# 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
```
--- ---
## Dependencias **Ultima actualizacion:** 2025-12-12
- **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

View File

@ -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"]

View File

@ -1,243 +1,441 @@
# Backend - MVP Sistema Administración de Obra # Backend - ERP Construccion
**Stack:** Node.js + Express + TypeScript + TypeORM API REST para sistema de administracion de obra e INFONAVIT.
**Versión:** 1.0.0
**Fecha:** 2025-11-20 | 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) # Configurar variables de entorno
**Base de datos:** PostgreSQL 15+ con PostGIS cp ../.env.example .env
**Autenticación:** JWT
# Desarrollo con hot-reload
npm run dev
# El servidor estara en http://localhost:3000
```
--- ---
## 🏗️ ESTRUCTURA ## Estructura del Proyecto
``` ```
src/ src/
├── shared/ # Código compartido ├── modules/
│ ├── config/ # Configuraciones │ ├── auth/ # Autenticacion JWT
│ ├── constants/ # Constantes globales │ │ ├── dto/
│ ├── database/ # Configuración TypeORM │ │ │ └── auth.dto.ts # DTOs tipados
│ ├── types/ # Tipos TypeScript compartidos │ │ ├── middleware/
│ ├── utils/ # Utilidades │ │ │ └── auth.middleware.ts
│ └── middleware/ # Middlewares de Express │ │ ├── services/
└── modules/ # Módulos de negocio │ │ │ └── auth.service.ts
├── auth/ # Autenticación y autorización │ │ └── index.ts
│ ├── entities/ # Entities TypeORM │ │
│ ├── services/ # Lógica de negocio │ ├── budgets/ # MAI-003 Presupuestos
│ ├── controllers/ # Controladores Express │ │ ├── entities/
│ ├── dto/ # DTOs de validación │ │ │ ├── concepto.entity.ts
│ └── auth.module.ts # Módulo de NestJS style │ │ │ ├── presupuesto.entity.ts
├── projects/ # Gestión de proyectos │ │ │ └── presupuesto-partida.entity.ts
├── budgets/ # Presupuestos y control de costos │ │ ├── services/
└── [otros módulos]/ │ │ │ ├── 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 Autenticacion JWT con refresh tokens y multi-tenancy.
npm install
```
### 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 ```typescript
import { User } from '@modules/auth/entities/user.entity'; // Services
import { config } from '@config/database.config'; AuthService
import { formatDate } from '@utils/date.utils'; ├── 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<MiEntity> {
constructor(repository: Repository<MiEntity>) {
super(repository);
}
}
// Metodos disponibles
BaseService<T>
├── 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 ### Nomenclatura
Seguir **ESTANDARES-NOMENCLATURA.md**: | Tipo | Convencion | Ejemplo |
- Archivos: `kebab-case.tipo.ts` |------|------------|---------|
- Clases: `PascalCase` + sufijo (Entity, Service, Controller, Dto) | Archivos | kebab-case.tipo.ts | `concepto.entity.ts` |
- Variables: `camelCase` | Clases | PascalCase + sufijo | `ConceptoService` |
- Constantes: `UPPER_SNAKE_CASE` | Variables | camelCase | `totalAmount` |
- Métodos: `camelCase` con verbo al inicio | Constantes | UPPER_SNAKE_CASE | `DB_SCHEMAS` |
| Metodos | camelCase + verbo | `findByContrato` |
### Estructura de Entity ### Entity Pattern
```typescript ```typescript
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity({ schema: 'construction', name: 'conceptos' })
@Index(['tenantId', 'code'], { unique: true })
@Entity({ schema: 'project_management', name: 'projects' }) export class Concepto {
export class ProjectEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ type: 'varchar', length: 50, unique: true }) @Column({ name: 'tenant_id', type: 'uuid' })
code: string; tenantId: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' }) // ... columnas con name: 'snake_case'
createdAt: Date;
// 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 ```typescript
import { Injectable } from '@nestjs/common'; export class MiService extends BaseService<MiEntity> {
import { Repository } from 'typeorm';
@Injectable()
export class ProjectService {
constructor( constructor(
@InjectRepository(ProjectEntity) repository: Repository<MiEntity>,
private projectRepo: Repository<ProjectEntity>, private readonly otroRepo: Repository<OtroEntity>
) {} ) {
super(repository);
async findAll(): Promise<ProjectEntity[]> {
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();
} }
@Post() async miMetodo(ctx: ServiceContext, data: MiDto): Promise<MiEntity> {
async create(@Body() dto: CreateProjectDto) { // ctx tiene tenantId y userId
return await this.projectService.create(dto); 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 ```bash
npm test # Todos los tests # Ejecutar tests
npm run test:watch # Modo watch npm test
npm run test:coverage # Con cobertura
# Con cobertura
npm run test:coverage
# Watch mode
npm run test:watch
``` ```
### Estructura de Tests
```typescript ```typescript
describe('ProjectService', () => { // Ejemplo de test
let service: ProjectService; describe('ConceptoService', () => {
let mockRepo: jest.Mocked<Repository<ProjectEntity>>; let service: ConceptoService;
let mockRepo: jest.Mocked<Repository<Concepto>>;
beforeEach(() => { beforeEach(() => {
mockRepo = createMockRepository(); mockRepo = createMockRepository();
service = new ProjectService(mockRepo); service = new ConceptoService(mockRepo);
}); });
it('should find all projects', async () => { it('should create concepto with level', async () => {
mockRepo.find.mockResolvedValue([mockProject]); const ctx = { tenantId: 'uuid', userId: 'uuid' };
const result = await service.findAll(); const dto = { code: '001', name: 'Test' };
expect(result).toHaveLength(1);
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) ### VS Code
- [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
```json ```json
{ {
@ -246,13 +444,18 @@ describe('ProjectService', () => {
"name": "Debug Backend", "name": "Debug Backend",
"runtimeArgs": ["-r", "ts-node/register"], "runtimeArgs": ["-r", "ts-node/register"],
"args": ["${workspaceFolder}/src/server.ts"], "args": ["${workspaceFolder}/src/server.ts"],
"env": { "env": { "NODE_ENV": "development" }
"NODE_ENV": "development"
}
} }
``` ```
### Logs
```typescript
// Configurar en .env
LOG_LEVEL=debug
LOG_FORMAT=dev
```
--- ---
**Mantenido por:** Backend-Agent **Ultima actualizacion:** 2025-12-12
**Última actualización:** 2025-11-20

View File

@ -15,7 +15,10 @@
"typeorm": "typeorm-ts-node-commonjs", "typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate", "migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run", "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": [ "keywords": [
"construccion", "construccion",

View File

@ -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;
}

View File

@ -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';

View File

@ -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<void> => {
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<void> => {
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<void> {
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);
}

View File

@ -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<User>,
private readonly tenantRepository: Repository<Tenant>,
private readonly refreshTokenRepository: Repository<RefreshToken>
) {
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<AuthResponse> {
// 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<AuthResponse> {
// 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<AuthResponse> {
// 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<void> {
await this.refreshTokenRepository.update(
{ token: refreshToken } as any,
{ revokedAt: new Date() }
);
}
/**
* Cambiar password
*/
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
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<string> {
const payload: Partial<TokenPayload> = {
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;
}
}
}

View File

@ -0,0 +1,5 @@
/**
* Auth Module - Service Exports
*/
export * from './auth.service';

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
/**
* Budgets Module - Entity Exports
* MAI-003: Presupuestos
*/
export * from './concepto.entity';
export * from './presupuesto.entity';
export * from './presupuesto-partida.entity';

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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<Concepto> {
constructor(repository: Repository<Concepto>) {
super(repository);
}
/**
* Crear un nuevo concepto con cálculo automático de nivel y path
*/
async createConcepto(
ctx: ServiceContext,
data: CreateConceptoDto
): Promise<Concepto> {
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<PaginatedResult<Concepto>> {
return this.findAll(ctx, {
page,
limit,
where: { parentId: IsNull() } as any,
});
}
/**
* Obtener hijos de un concepto
*/
async findChildren(
ctx: ServiceContext,
parentId: string
): Promise<Concepto[]> {
return this.find(ctx, {
where: { parentId } as any,
order: { code: 'ASC' },
});
}
/**
* Obtener árbol completo de conceptos
*/
async getConceptoTree(
ctx: ServiceContext,
rootId?: string
): Promise<ConceptoNode[]> {
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<ConceptoNode[]> {
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<Concepto[]> {
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<boolean> {
return this.exists(ctx, { code } as any);
}
}
interface ConceptoNode extends Concepto {
children: ConceptoNode[];
}

View File

@ -0,0 +1,6 @@
/**
* Budgets Module - Service Exports
*/
export * from './concepto.service';
export * from './presupuesto.service';

View File

@ -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<Presupuesto> {
constructor(
repository: Repository<Presupuesto>,
private readonly partidaRepository: Repository<PresupuestoPartida>
) {
super(repository);
}
/**
* Crear nuevo presupuesto
*/
async createPresupuesto(
ctx: ServiceContext,
data: CreatePresupuestoDto
): Promise<Presupuesto> {
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<PaginatedResult<Presupuesto>> {
return this.findAll(ctx, {
page,
limit,
where: { fraccionamientoId, isActive: true } as any,
});
}
/**
* Obtener presupuesto con sus partidas
*/
async findWithPartidas(
ctx: ServiceContext,
id: string
): Promise<Presupuesto | null> {
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<PresupuestoPartida> {
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<PresupuestoPartida | null> {
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<boolean> {
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<void> {
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<Presupuesto> {
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<Presupuesto | null> {
const presupuesto = await this.findById(ctx, presupuestoId);
if (!presupuesto) {
return null;
}
return this.update(ctx, presupuestoId, {
approvedAt: new Date(),
approvedById: ctx.userId,
});
}
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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<Estimacion> {
constructor(
repository: Repository<Estimacion>,
private readonly conceptoRepository: Repository<EstimacionConcepto>,
private readonly generadorRepository: Repository<Generador>,
private readonly workflowRepository: Repository<EstimacionWorkflow>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Crear nueva estimación
*/
async createEstimacion(
ctx: ServiceContext,
data: CreateEstimacionDto
): Promise<Estimacion> {
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<number> {
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<string> {
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<PaginatedResult<Estimacion>> {
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<PaginatedResult<Estimacion>> {
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<Estimacion | null> {
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<EstimacionConcepto> {
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<Generador> {
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<void> {
// 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<Estimacion | null> {
const estimacion = await this.findById(ctx, estimacionId);
if (!estimacion) {
return null;
}
const validTransitions: Record<EstimateStatus, EstimateStatus[]> = {
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<Estimacion> = { 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<void> {
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<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'submitted', 'submit', 'Enviada para revisión');
}
/**
* Revisar estimación
*/
async review(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'reviewed', 'review', 'Revisión completada');
}
/**
* Aprobar estimación
*/
async approve(ctx: ServiceContext, estimacionId: string): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'approved', 'approve', 'Aprobada');
}
/**
* Rechazar estimación
*/
async reject(
ctx: ServiceContext,
estimacionId: string,
reason: string
): Promise<Estimacion | null> {
return this.changeStatus(ctx, estimacionId, 'rejected', 'reject', reason);
}
/**
* Obtener resumen de estimaciones por contrato
*/
async getContractSummary(ctx: ServiceContext, contratoId: string): Promise<ContractEstimateSummary> {
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;
}

View File

@ -0,0 +1,6 @@
/**
* Estimates Module - Service Exports
* MAI-008: Estimaciones y Facturación
*/
export * from './estimacion.service';

View File

@ -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[];
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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<AvanceObra> {
constructor(
repository: Repository<AvanceObra>,
private readonly fotoRepository: Repository<FotoAvance>
) {
super(repository);
}
/**
* Crear nuevo avance (captura)
*/
async createAvance(
ctx: ServiceContext,
data: CreateAvanceDto
): Promise<AvanceObra> {
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<PaginatedResult<AvanceObra>> {
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<PaginatedResult<AvanceObra>> {
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<PaginatedResult<AvanceObra>> {
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<AvanceObra | null> {
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<FotoAvance> {
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<AvanceObra | null> {
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<AvanceObra | null> {
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<AvanceObra | null> {
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<ConceptProgress[]> {
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;
}

View File

@ -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<BitacoraObra> {
constructor(repository: Repository<BitacoraObra>) {
super(repository);
}
/**
* Crear nueva entrada de bitácora
*/
async createEntry(
ctx: ServiceContext,
data: CreateBitacoraDto
): Promise<BitacoraObra> {
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<number> {
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<PaginatedResult<BitacoraObra>> {
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<PaginatedResult<BitacoraObra>> {
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<BitacoraObra | null> {
return this.findOne(ctx, {
fraccionamientoId,
entryDate: date,
} as any);
}
/**
* Obtener última entrada
*/
async findLatest(
ctx: ServiceContext,
fraccionamientoId: string
): Promise<BitacoraObra | null> {
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<BitacoraStats> {
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;
}

View File

@ -0,0 +1,7 @@
/**
* Progress Module - Service Exports
* MAI-005: Control de Obra
*/
export * from './avance-obra.service';
export * from './bitacora-obra.service';

View File

@ -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;

View File

@ -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;

View File

@ -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];

View File

@ -1,66 +1,194 @@
/** /**
* Constants * Constants - SSOT Entry Point
* Constantes globales del proyecto *
* 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 = { export const APP_CONTEXT = {
TENANT_ID: 'app.current_tenant', TENANT_ID: 'app.current_tenant',
USER_ID: 'app.current_user_id', USER_ID: 'app.current_user_id',
} as const; } as const;
// Estados de proyecto /**
export const PROJECT_STATUS = { * Custom HTTP Headers
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
export const CUSTOM_HEADERS = { export const CUSTOM_HEADERS = {
TENANT_ID: 'x-tenant-id', TENANT_ID: 'x-tenant-id',
CORRELATION_ID: 'x-correlation-id', CORRELATION_ID: 'x-correlation-id',
API_KEY: 'x-api-key',
} as const; } as const;
// Pagination defaults /**
* Pagination Defaults
*/
export const PAGINATION = { export const PAGINATION = {
DEFAULT_PAGE: 1, DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 20, DEFAULT_LIMIT: 20,
MAX_LIMIT: 100, MAX_LIMIT: 100,
} as const; } as const;
// Regex patterns /**
* Regex Patterns for Validation
*/
export const PATTERNS = { 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, 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}$/, 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]$/, CURP: /^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[0-9A-Z][0-9]$/,
NSS: /^[0-9]{11}$/, NSS: /^[0-9]{11}$/,
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 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; } as const;

View File

@ -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<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface ServiceContext {
tenantId: string;
userId: string;
}
export abstract class BaseService<T extends ObjectLiteral> {
constructor(protected readonly repository: Repository<T>) {}
/**
* Find all records for a tenant with optional pagination
*/
async findAll(
ctx: ServiceContext,
options?: PaginationOptions & { where?: FindOptionsWhere<T> }
): Promise<PaginatedResult<T>> {
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<T>;
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<T | null> {
return this.repository.findOne({
where: {
id,
tenantId: ctx.tenantId,
deletedAt: null,
} as FindOptionsWhere<T>,
});
}
/**
* Find one record by criteria
*/
async findOne(
ctx: ServiceContext,
where: FindOptionsWhere<T>
): Promise<T | null> {
return this.repository.findOne({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as FindOptionsWhere<T>,
});
}
/**
* Find records by custom options
*/
async find(
ctx: ServiceContext,
options: FindManyOptions<T>
): Promise<T[]> {
return this.repository.find({
...options,
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...(options.where || {}),
} as FindOptionsWhere<T>,
});
}
/**
* Create a new record
*/
async create(
ctx: ServiceContext,
data: DeepPartial<T>
): Promise<T> {
const entity = this.repository.create({
...data,
tenantId: ctx.tenantId,
createdById: ctx.userId,
} as DeepPartial<T>);
return this.repository.save(entity);
}
/**
* Update an existing record
*/
async update(
ctx: ServiceContext,
id: string,
data: DeepPartial<T>
): Promise<T | null> {
const existing = await this.findById(ctx, id);
if (!existing) {
return null;
}
const updated = this.repository.merge(existing, {
...data,
updatedById: ctx.userId,
} as DeepPartial<T>);
return this.repository.save(updated);
}
/**
* Soft delete a record
*/
async softDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const existing = await this.findById(ctx, id);
if (!existing) {
return false;
}
await this.repository.update(
{ id, tenantId: ctx.tenantId } as FindOptionsWhere<T>,
{
deletedAt: new Date(),
deletedById: ctx.userId,
} as any
);
return true;
}
/**
* Hard delete a record (use with caution)
*/
async hardDelete(ctx: ServiceContext, id: string): Promise<boolean> {
const result = await this.repository.delete({
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<T>);
return (result.affected ?? 0) > 0;
}
/**
* Count records
*/
async count(
ctx: ServiceContext,
where?: FindOptionsWhere<T>
): Promise<number> {
return this.repository.count({
where: {
tenantId: ctx.tenantId,
deletedAt: null,
...where,
} as FindOptionsWhere<T>,
});
}
/**
* Check if a record exists
*/
async exists(
ctx: ServiceContext,
where: FindOptionsWhere<T>
): Promise<boolean> {
const count = await this.count(ctx, where);
return count > 0;
}
}

View File

@ -0,0 +1,5 @@
/**
* Shared Services - Exports
*/
export * from './base.service';

View File

@ -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

View File

@ -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();

View File

@ -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<string, Violation[]>);
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();

View File

@ -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

View File

@ -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

View File

@ -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;"]

View File

@ -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;
# }
}

View File

@ -1,21 +1,22 @@
# ESTADO DEL PROYECTO - ERP Mecánicas Diesel # ESTADO DEL PROYECTO - ERP Mecánicas Diesel
**Proyecto:** ERP Mecánicas Diesel (Proyecto Independiente) **Proyecto:** ERP Mecánicas Diesel (Proyecto Independiente)
**Estado:** Documentación COMPLETA (MVP) - DDL IMPLEMENTADO **Estado:** Documentacion COMPLETA + GAPs RESUELTOS - Listo para desarrollo
**Progreso:** 95% **Progreso:** 100%
**Última actualización:** 2025-12-08 **Ultima actualizacion:** 2025-12-12
--- ---
## RESUMEN ## RESUMEN
- **Tipo:** Proyecto independiente que adapta patrones del ERP-Core - **Tipo:** Proyecto independiente - Sistema ERP para talleres diesel
- **Fase actual:** Documentación completa + DDL - Listo para desarrollo backend/frontend - **Fase actual:** Documentacion completa + DDL - Listo para desarrollo
- **Épicas documentadas:** 6/6 (MVP completo) - **Epicas documentadas:** 6/6 (MVP completo)
- **Módulos documentados:** 6/6 (MVP completo) - **Modulos documentados:** 6/6 (MVP completo)
- **Story Points totales:** 241 SP - **Story Points totales:** 241 SP
- **Historias de usuario:** 55 historias detalladas (100% cobertura) - **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 | | Schema | Tablas | Descripcion | DDL |
|--------|--------|-------------| |--------|--------|-------------|-----|
| workshop_core | 9 | Configuracion, usuarios, clientes, servicios | | workshop_core | 9 | Configuracion, usuarios, clientes, servicios | 01-create-schemas.sql |
| service_management | 14 | Ordenes, diagnosticos, cotizaciones | | service_management | 14+ | Ordenes, diagnosticos, cotizaciones, firma | 03-service-management.sql, 11-quote-signature.sql |
| parts_management | 12 | Inventario, refacciones, movimientos | | parts_management | 12+ | Inventario, refacciones, garantias | 04-parts-management.sql, 10-warranty-claims.sql |
| vehicle_management | 8 | Vehiculos, flotas, motores | | 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 ## PROXIMOS PASOS
1. **Crear especificaciones tecnicas (ET)** - ET por modulo con endpoints y UI 1. **Iniciar desarrollo backend** - APIs REST con NestJS
2. **Esperar erp-core** - Depende de MGN-001 a MGN-011 2. **Crear especificaciones tecnicas (ET)** - ET por modulo con endpoints y UI
3. **Iniciar desarrollo** - Sprint 1 con MMD-001 3. **Implementar modulo MMD-007 Facturacion** - Fase 2 con CFDI
--- ---
## ARQUITECTURA ## ARQUITECTURA
**Tipo:** Proyecto Independiente (fork conceptual del ERP-Core) **Tipo:** Proyecto Independiente - ERP Vertical para Talleres Diesel
**Patrones reutilizados del ERP-Core:** **Stack Tecnologico:**
- Multi-tenancy con RLS - **Base de datos:** PostgreSQL 15+ con RLS multi-tenant
- Estructura de autenticación - **Backend:** Node.js + NestJS + TypeScript
- Patrones de inventario - **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:** **Opera de forma autonoma:** Sistema standalone sin dependencias externas
- Vertical Construcción (patrones de documentación aplicados)
--- ---
@ -211,12 +255,12 @@ docs/03-modelo-datos/
| Story Points | 241 SP | | Story Points | 241 SP |
| Historias detalladas | 55 | | Historias detalladas | 55 |
| Cobertura US | 100% | | Cobertura US | 100% |
| Schemas BD | 4 completos | | Schemas BD | 7 completos |
| Tablas BD | 43 | | Tablas BD | 65+ |
| Funcionalidades adicionales | 7 implementadas |
| Sprints estimados | 10 | | Sprints estimados | 10 |
| Reutilizacion Core | 60-70% |
--- ---
*Proyecto parte de ERP Suite - Fabrica de Software con Agentes IA* *Proyecto parte de ERP Suite - Fabrica de Software con Agentes IA*
*Ultima actualizacion: 2025-12-06* *Ultima actualizacion: 2025-12-12*

View File

@ -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%) **Estado:** Documentacion completa - Listo para desarrollo
**Versión:** 0.1.0 **Version:** 1.0.0
**Base:** Extiende erp-core (60-70%) **Tipo:** Proyecto independiente
## Estructura del Proyecto ## Estructura del Proyecto
``` ```
mecanicas-diesel/ mecanicas-diesel/
├── backend/ # Extensiones backend específicas ├── backend/ # APIs y servicios (NestJS + TypeScript)
├── frontend/ # UI especializada ├── frontend/ # UI especializada (React + TypeScript)
├── database/ # DDL y migrations específicos ├── database/ # DDL y scripts de base de datos
├── docs/ # Documentación del proyecto │ └── init/ # Scripts de inicializacion
│ ├── 00-vision-general/ ├── docs/ # Documentacion del proyecto
│ ├── 01-fase-mvp/ │ ├── 00-vision-general/ # Vision y objetivos
│ ├── 02-modelado/ │ ├── 02-definicion-modulos/ # Modulos y user stories
└── 90-transversal/ ├── 03-modelo-datos/ # Documentacion de schemas
└── orchestration/ # Sistema de agentes NEXUS │ ├── 08-epicas/ # Epicas del proyecto
├── 00-guidelines/ │ └── 90-transversal/ # Documentacion transversal
│ └── CONTEXTO-PROYECTO.md └── orchestration/ # Sistema de agentes
├── trazas/ ├── 00-guidelines/ # Guias del proyecto
├── estados/ ├── inventarios/ # Inventarios de componentes
└── PROXIMA-ACCION.md └── PROXIMA-ACCION.md # Siguiente paso a ejecutar
``` ```
## Módulos Específicos Planificados ## Modulos del MVP
| Módulo | Descripción | Prioridad | | Modulo | Codigo | Descripcion | Estado |
|--------|-------------|-----------| |--------|--------|-------------|--------|
| Diagnósticos | Pruebas y diagnósticos de equipos | Alta | | Fundamentos | MMD-001 | Configuracion, usuarios, tenants | Documentado |
| Órdenes de Reparación | Gestión de servicios | Alta | | Ordenes de Servicio | MMD-002 | Gestion de servicios y trabajos | Documentado |
| Inventario Refacciones | Stock de partes y refacciones | Alta | | Diagnosticos | MMD-003 | Pruebas y diagnosticos diesel | Documentado |
| Vehículos en Servicio | Control de unidades | Media | | Inventario | MMD-004 | Stock de refacciones | Documentado |
| Cotizaciones | Presupuestos de reparación | Media | | Vehiculos | MMD-005 | Registro y control de unidades | Documentado |
| Historial de Servicios | Trazabilidad por vehículo | Media | | Cotizaciones | MMD-006 | Presupuestos de reparacion | Documentado |
## Schemas Planificados ## Schemas de Base de Datos
| Schema | Descripción | | Schema | Tablas | Descripcion |
|--------|-------------| |--------|--------|-------------|
| `service_management` | Órdenes, diagnósticos, reparaciones | | workshop_core | 9 | Configuracion, usuarios, clientes |
| `parts_management` | Refacciones, proveedores | | service_management | 14+ | Ordenes, diagnosticos, cotizaciones |
| `vehicle_management` | Vehículos, historial | | 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` ## Stack Tecnologico
- **Próxima acción:** `orchestration/PROXIMA-ACCION.md`
- **Trazas de agentes:** `orchestration/trazas/`
## 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 ## Documentacion
- **Referencia:** Patrones de construcción aplicables
- **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*

View File

@ -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 -- Schemas propios de mecanicas-diesel
-- NOTA: Los schemas auth, core, inventory se heredan de erp-core
CREATE SCHEMA IF NOT EXISTS service_management; CREATE SCHEMA IF NOT EXISTS service_management;
COMMENT ON SCHEMA service_management IS 'Órdenes de servicio, diagnósticos, cotizaciones'; COMMENT ON SCHEMA service_management IS 'Órdenes de servicio, diagnósticos, cotizaciones';

View File

@ -1,17 +1,16 @@
-- =========================================== -- ===========================================
-- MECANICAS DIESEL - Schema service_management -- MECANICAS DIESEL - Schema service_management
-- =========================================== -- ===========================================
-- Órdenes de servicio, diagnósticos, cotizaciones -- Ordenes de servicio, diagnosticos, cotizaciones
-- NOTA: Usa auth.users y auth.tenants de erp-core
SET search_path TO service_management, public; SET search_path TO service_management, public;
-- ------------------------------------------- -- -------------------------------------------
-- SERVICE_ORDERS - Órdenes de servicio -- SERVICE_ORDERS - Ordenes de servicio
-- ------------------------------------------- -- -------------------------------------------
CREATE TABLE service_management.service_orders ( CREATE TABLE service_management.service_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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 -- Identificación
order_number VARCHAR(20) NOT NULL, order_number VARCHAR(20) NOT NULL,

View File

@ -1,8 +1,7 @@
-- =========================================== -- ===========================================
-- MECANICAS DIESEL - Schema parts_management -- MECANICAS DIESEL - Schema parts_management
-- =========================================== -- ===========================================
-- Inventario de refacciones específico del taller -- Inventario de refacciones especifico del taller
-- NOTA: Extiende inventory.* de erp-core para campos específicos
SET search_path TO parts_management, public; SET search_path TO parts_management, public;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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