/** * BitacoraObraService - Bitácora de Obra * * Gestiona el registro diario de bitácora de obra. * Genera automáticamente el número de entrada secuencial. * * @module Progress */ import { Repository } from 'typeorm'; import { BaseService, ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; import { BitacoraObra } from '../entities/bitacora-obra.entity'; export interface CreateBitacoraDto { fraccionamientoId: string; entryDate: Date; weather?: string; temperatureMax?: number; temperatureMin?: number; workersCount?: number; description: string; observations?: string; incidents?: string; } export interface UpdateBitacoraDto { weather?: string; temperatureMax?: number; temperatureMin?: number; workersCount?: number; description?: string; observations?: string; incidents?: string; } export interface BitacoraFilters { dateFrom?: Date; dateTo?: Date; hasIncidents?: boolean; } export class BitacoraObraService extends BaseService { constructor(repository: Repository) { super(repository); } /** * Crear nueva entrada de bitácora */ async createEntry( ctx: ServiceContext, data: CreateBitacoraDto ): Promise { const entryNumber = await this.getNextEntryNumber(ctx, data.fraccionamientoId); return this.create(ctx, { ...data, entryNumber, registeredById: ctx.userId, }); } /** * Obtener siguiente número de entrada */ private async getNextEntryNumber( ctx: ServiceContext, fraccionamientoId: string ): Promise { const result = await this.repository .createQueryBuilder('b') .select('MAX(b.entry_number)', 'maxNumber') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) .getRawOne(); return (result?.maxNumber || 0) + 1; } /** * Obtener bitácora por fraccionamiento */ async findByFraccionamiento( ctx: ServiceContext, fraccionamientoId: string, page = 1, limit = 20 ): Promise> { return this.findAll(ctx, { page, limit, where: { fraccionamientoId } as any, }); } /** * Obtener bitácora con filtros */ async findWithFilters( ctx: ServiceContext, fraccionamientoId: string, filters: BitacoraFilters, page = 1, limit = 20 ): Promise> { const qb = this.repository .createQueryBuilder('b') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) .andWhere('b.deleted_at IS NULL'); if (filters.dateFrom) { qb.andWhere('b.entry_date >= :dateFrom', { dateFrom: filters.dateFrom }); } if (filters.dateTo) { qb.andWhere('b.entry_date <= :dateTo', { dateTo: filters.dateTo }); } if (filters.hasIncidents !== undefined) { if (filters.hasIncidents) { qb.andWhere('b.incidents IS NOT NULL'); } else { qb.andWhere('b.incidents IS NULL'); } } const skip = (page - 1) * limit; qb.orderBy('b.entry_date', 'DESC').skip(skip).take(limit); const [data, total] = await qb.getManyAndCount(); return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } /** * Obtener entrada por fecha */ async findByDate( ctx: ServiceContext, fraccionamientoId: string, date: Date ): Promise { return this.findOne(ctx, { fraccionamientoId, entryDate: date, } as any); } /** * Obtener última entrada */ async findLatest( ctx: ServiceContext, fraccionamientoId: string ): Promise { const entries = await this.find(ctx, { where: { fraccionamientoId } as any, order: { entryNumber: 'DESC' }, take: 1, }); return entries[0] || null; } /** * Obtener estadísticas de bitácora */ async getStats( ctx: ServiceContext, fraccionamientoId: string ): Promise { const totalEntries = await this.count(ctx, { fraccionamientoId } as any); const incidentsCount = await this.repository .createQueryBuilder('b') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) .andWhere('b.deleted_at IS NULL') .andWhere('b.incidents IS NOT NULL') .getCount(); const avgWorkers = await this.repository .createQueryBuilder('b') .select('AVG(b.workers_count)', 'avg') .where('b.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('b.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId }) .andWhere('b.deleted_at IS NULL') .getRawOne(); return { totalEntries, entriesWithIncidents: incidentsCount, avgWorkersCount: parseFloat(avgWorkers?.avg || '0'), }; } } interface BitacoraStats { totalEntries: number; entriesWithIncidents: number; avgWorkersCount: number; }