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
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:
parent
48e7235e79
commit
7a0e02cde8
@ -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) => {
|
||||||
|
|||||||
@ -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();
|
||||||
@ -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;
|
||||||
@ -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();
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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();
|
||||||
@ -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();
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
@ -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();
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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();
|
||||||
@ -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();
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export * from './reports.service.js';
|
||||||
|
export * from './reports.controller.js';
|
||||||
|
export { default as reportsRoutes } from './reports.routes.js';
|
||||||
@ -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();
|
||||||
@ -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;
|
||||||
@ -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();
|
||||||
@ -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];
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
@ -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 $$;
|
||||||
@ -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 $$;
|
||||||
@ -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 $$;
|
||||||
119
projects/erp-suite/apps/verticales/construccion/.env.example
Normal file
119
projects/erp-suite/apps/verticales/construccion/.env.example
Normal 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
|
||||||
263
projects/erp-suite/apps/verticales/construccion/.github/workflows/ci.yml
vendored
Normal file
263
projects/erp-suite/apps/verticales/construccion/.github/workflows/ci.yml
vendored
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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"]
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Auth Module - Service Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './auth.service';
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Budgets Module - Entity Exports
|
||||||
|
* MAI-003: Presupuestos
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './concepto.entity';
|
||||||
|
export * from './presupuesto.entity';
|
||||||
|
export * from './presupuesto-partida.entity';
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Budgets Module - Service Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './concepto.service';
|
||||||
|
export * from './presupuesto.service';
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Estimates Module - Service Exports
|
||||||
|
* MAI-008: Estimaciones y Facturación
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './estimacion.service';
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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';
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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[];
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Progress Module - Service Exports
|
||||||
|
* MAI-005: Control de Obra
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './avance-obra.service';
|
||||||
|
export * from './bitacora-obra.service';
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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];
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Shared Services - Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './base.service';
|
||||||
306
projects/erp-suite/apps/verticales/construccion/database/_MAP.md
Normal file
306
projects/erp-suite/apps/verticales/construccion/database/_MAP.md
Normal 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
|
||||||
@ -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();
|
||||||
@ -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();
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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;"]
|
||||||
@ -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;
|
||||||
|
# }
|
||||||
|
}
|
||||||
@ -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*
|
||||||
|
|||||||
@ -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*
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user