erp-core-backend/src/modules/projects/projects.service.ts
2025-12-12 14:39:29 -06:00

310 lines
10 KiB
TypeScript

import { query, queryOne } from '../../config/database.js';
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
export interface Project {
id: string;
tenant_id: string;
company_id: string;
company_name?: string;
name: string;
code?: string;
description?: string;
manager_id?: string;
manager_name?: string;
partner_id?: string;
partner_name?: string;
analytic_account_id?: string;
date_start?: Date;
date_end?: Date;
status: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
privacy: 'public' | 'private' | 'followers';
allow_timesheets: boolean;
color?: string;
task_count?: number;
completed_task_count?: number;
created_at: Date;
}
export interface CreateProjectDto {
company_id: string;
name: string;
code?: string;
description?: string;
manager_id?: string;
partner_id?: string;
date_start?: string;
date_end?: string;
privacy?: 'public' | 'private' | 'followers';
allow_timesheets?: boolean;
color?: string;
}
export interface UpdateProjectDto {
name?: string;
code?: string | null;
description?: string | null;
manager_id?: string | null;
partner_id?: string | null;
date_start?: string | null;
date_end?: string | null;
status?: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold';
privacy?: 'public' | 'private' | 'followers';
allow_timesheets?: boolean;
color?: string | null;
}
export interface ProjectFilters {
company_id?: string;
manager_id?: string;
partner_id?: string;
status?: string;
search?: string;
page?: number;
limit?: number;
}
class ProjectsService {
async findAll(tenantId: string, filters: ProjectFilters = {}): Promise<{ data: Project[]; total: number }> {
const { company_id, manager_id, partner_id, status, search, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE p.tenant_id = $1 AND p.deleted_at IS NULL';
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND p.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (manager_id) {
whereClause += ` AND p.manager_id = $${paramIndex++}`;
params.push(manager_id);
}
if (partner_id) {
whereClause += ` AND p.partner_id = $${paramIndex++}`;
params.push(partner_id);
}
if (status) {
whereClause += ` AND p.status = $${paramIndex++}`;
params.push(status);
}
if (search) {
whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.code ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM projects.projects p ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<Project>(
`SELECT p.*,
c.name as company_name,
u.name as manager_name,
pr.name as partner_name,
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count,
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count
FROM projects.projects p
LEFT JOIN auth.companies c ON p.company_id = c.id
LEFT JOIN auth.users u ON p.manager_id = u.id
LEFT JOIN core.partners pr ON p.partner_id = pr.id
${whereClause}
ORDER BY p.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findById(id: string, tenantId: string): Promise<Project> {
const project = await queryOne<Project>(
`SELECT p.*,
c.name as company_name,
u.name as manager_name,
pr.name as partner_name,
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count,
(SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count
FROM projects.projects p
LEFT JOIN auth.companies c ON p.company_id = c.id
LEFT JOIN auth.users u ON p.manager_id = u.id
LEFT JOIN core.partners pr ON p.partner_id = pr.id
WHERE p.id = $1 AND p.tenant_id = $2 AND p.deleted_at IS NULL`,
[id, tenantId]
);
if (!project) {
throw new NotFoundError('Proyecto no encontrado');
}
return project;
}
async create(dto: CreateProjectDto, tenantId: string, userId: string): Promise<Project> {
// Check unique code if provided
if (dto.code) {
const existing = await queryOne(
`SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
[dto.company_id, dto.code]
);
if (existing) {
throw new ConflictError('Ya existe un proyecto con ese código');
}
}
const project = await queryOne<Project>(
`INSERT INTO projects.projects (
tenant_id, company_id, name, code, description, manager_id, partner_id,
date_start, date_end, privacy, allow_timesheets, color, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`,
[
tenantId, dto.company_id, dto.name, dto.code, dto.description,
dto.manager_id, dto.partner_id, dto.date_start, dto.date_end,
dto.privacy || 'public', dto.allow_timesheets ?? true, dto.color, userId
]
);
return project!;
}
async update(id: string, dto: UpdateProjectDto, tenantId: string, userId: string): Promise<Project> {
const existing = await this.findById(id, tenantId);
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.name !== undefined) {
updateFields.push(`name = $${paramIndex++}`);
values.push(dto.name);
}
if (dto.code !== undefined) {
if (dto.code) {
const existingCode = await queryOne(
`SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND id != $3 AND deleted_at IS NULL`,
[existing.company_id, dto.code, id]
);
if (existingCode) {
throw new ConflictError('Ya existe un proyecto con ese código');
}
}
updateFields.push(`code = $${paramIndex++}`);
values.push(dto.code);
}
if (dto.description !== undefined) {
updateFields.push(`description = $${paramIndex++}`);
values.push(dto.description);
}
if (dto.manager_id !== undefined) {
updateFields.push(`manager_id = $${paramIndex++}`);
values.push(dto.manager_id);
}
if (dto.partner_id !== undefined) {
updateFields.push(`partner_id = $${paramIndex++}`);
values.push(dto.partner_id);
}
if (dto.date_start !== undefined) {
updateFields.push(`date_start = $${paramIndex++}`);
values.push(dto.date_start);
}
if (dto.date_end !== undefined) {
updateFields.push(`date_end = $${paramIndex++}`);
values.push(dto.date_end);
}
if (dto.status !== undefined) {
updateFields.push(`status = $${paramIndex++}`);
values.push(dto.status);
}
if (dto.privacy !== undefined) {
updateFields.push(`privacy = $${paramIndex++}`);
values.push(dto.privacy);
}
if (dto.allow_timesheets !== undefined) {
updateFields.push(`allow_timesheets = $${paramIndex++}`);
values.push(dto.allow_timesheets);
}
if (dto.color !== undefined) {
updateFields.push(`color = $${paramIndex++}`);
values.push(dto.color);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE projects.projects SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL`,
values
);
return this.findById(id, tenantId);
}
async delete(id: string, tenantId: string, userId: string): Promise<void> {
await this.findById(id, tenantId);
// Soft delete
await query(
`UPDATE projects.projects SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1
WHERE id = $2 AND tenant_id = $3`,
[userId, id, tenantId]
);
}
async getStats(id: string, tenantId: string): Promise<object> {
await this.findById(id, tenantId);
const stats = await queryOne<{
total_tasks: number;
completed_tasks: number;
in_progress_tasks: number;
total_hours: number;
total_milestones: number;
completed_milestones: number;
}>(
`SELECT
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND deleted_at IS NULL) as total_tasks,
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'done' AND deleted_at IS NULL) as completed_tasks,
(SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'in_progress' AND deleted_at IS NULL) as in_progress_tasks,
(SELECT COALESCE(SUM(hours), 0) FROM projects.timesheets WHERE project_id = $1) as total_hours,
(SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1) as total_milestones,
(SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1 AND status = 'completed') as completed_milestones`,
[id]
);
return {
total_tasks: parseInt(String(stats?.total_tasks || 0)),
completed_tasks: parseInt(String(stats?.completed_tasks || 0)),
in_progress_tasks: parseInt(String(stats?.in_progress_tasks || 0)),
completion_percentage: stats?.total_tasks
? Math.round((parseInt(String(stats.completed_tasks)) / parseInt(String(stats.total_tasks))) * 100)
: 0,
total_hours: parseFloat(String(stats?.total_hours || 0)),
total_milestones: parseInt(String(stats?.total_milestones || 0)),
completed_milestones: parseInt(String(stats?.completed_milestones || 0)),
};
}
}
export const projectsService = new ProjectsService();