210 lines
5.2 KiB
TypeScript
210 lines
5.2 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|