32 KiB
32 KiB
ET-PROG-003: Implementación de Evidencias Fotográficas y Checklists
Épica: MAI-005 - Control de Obra y Avances Módulo: Evidencias y Control de Calidad Responsable Técnico: Backend + Frontend + Mobile + Storage Fecha: 2025-11-17 Versión: 1.0
1. Objetivo Técnico
Implementar el sistema de evidencias fotográficas y checklists de calidad con:
- Captura de fotos con marca de agua automática
- Extracción de metadatos EXIF (fecha, GPS, dispositivo)
- Georreferenciación con PostGIS
- Verificación de integridad con SHA256
- Checklists de calidad configurables por etapa
- Generación de PDFs con firma digital
- Almacenamiento optimizado (compresión, thumbnails)
2. Stack Tecnológico
Backend
- NestJS 10+ con TypeScript
- TypeORM para PostgreSQL
- PostgreSQL 15+ (schema: evidence)
- PostGIS para coordenadas geográficas
- Sharp para procesamiento de imágenes
- ExifReader para metadatos EXIF
- crypto (SHA256) para hashes
- AWS S3 / Google Cloud Storage
- PDFKit para generación de PDFs
Frontend
- React 18 con TypeScript
- react-dropzone para upload de fotos
- Leaflet para visualización de mapas
- Canvas API para marcas de agua
- react-signature-canvas para firmas
Mobile
- React Native 0.72+
- react-native-camera / Expo Camera
- react-native-image-picker
- react-native-fs para sistema de archivos
- Geolocation API
3. Modelo de Datos SQL
-- =====================================================
-- SCHEMA: evidence
-- Descripción: Evidencias fotográficas y checklists
-- =====================================================
CREATE SCHEMA IF NOT EXISTS evidence;
-- =====================================================
-- TABLE: evidence.photos
-- Descripción: Evidencias fotográficas con georreferenciación
-- =====================================================
CREATE TABLE evidence.photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
stage_id UUID REFERENCES projects.stages(id),
workfront_id UUID REFERENCES projects.workfronts(id),
unit_id UUID REFERENCES projects.units(id),
activity_id UUID REFERENCES schedules.schedule_activities(id),
budget_item_id UUID REFERENCES budgets.budget_items(id),
-- Tipo de evidencia
photo_type VARCHAR(30) NOT NULL,
-- progress, incident, final, quality_check, before_after
-- Archivo
original_filename VARCHAR(255) NOT NULL,
stored_filename VARCHAR(255) NOT NULL,
file_path VARCHAR(512) NOT NULL,
file_size INTEGER NOT NULL, -- bytes
mime_type VARCHAR(50) NOT NULL,
-- Thumbnail
thumbnail_path VARCHAR(512),
thumbnail_size INTEGER,
-- Dimensiones
width INTEGER,
height INTEGER,
resolution VARCHAR(20), -- "1920x1080", "4000x3000"
-- Fechas
capture_date TIMESTAMP NOT NULL,
upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Marca de agua
has_watermark BOOLEAN DEFAULT false,
watermark_text TEXT,
-- Georreferenciación (PostGIS)
geolocation GEOMETRY(POINT, 4326),
geo_accuracy DECIMAL(8,2), -- metros
geo_verified BOOLEAN DEFAULT false,
distance_from_site DECIMAL(8,2), -- metros
-- Descripción
description TEXT,
tags VARCHAR[], -- array de etiquetas
-- Metadata del usuario
uploaded_by UUID NOT NULL REFERENCES auth.users(id),
uploaded_via VARCHAR(20) NOT NULL, -- web, mobile, api
-- Metadata del dispositivo
device_model VARCHAR(100),
device_os VARCHAR(50),
-- EXIF data completa
exif_data JSONB,
/* {
"Make": "Apple",
"Model": "iPhone 14 Pro",
"DateTime": "2025:01:15 10:30:25",
"GPSLatitude": 19.4326,
"GPSLongitude": -99.1332,
"GPSAltitude": 2240.5,
"Orientation": 1,
"Flash": "No Flash",
"FocalLength": "6.86 mm",
"ExposureTime": "1/120",
"ISO": 64
} */
-- Integridad
sha256_hash VARCHAR(64) NOT NULL, -- hash del archivo original
is_verified BOOLEAN DEFAULT false,
-- Vinculación
progress_record_id UUID REFERENCES progress.progress_records(id),
checklist_id UUID REFERENCES evidence.quality_checklists(id),
incident_id UUID,
-- Soft delete
is_deleted BOOLEAN DEFAULT false,
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id),
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_photos_project ON evidence.photos(project_id);
CREATE INDEX idx_photos_stage ON evidence.photos(stage_id);
CREATE INDEX idx_photos_unit ON evidence.photos(unit_id);
CREATE INDEX idx_photos_type ON evidence.photos(photo_type);
CREATE INDEX idx_photos_date ON evidence.photos(capture_date);
CREATE INDEX idx_photos_uploaded_by ON evidence.photos(uploaded_by);
CREATE INDEX idx_photos_not_deleted ON evidence.photos(is_deleted) WHERE is_deleted = false;
-- Índice espacial
CREATE INDEX idx_photos_geolocation ON evidence.photos USING GIST(geolocation);
-- =====================================================
-- TABLE: evidence.checklist_templates
-- Descripción: Plantillas de checklists de calidad
-- =====================================================
CREATE TABLE evidence.checklist_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
template_code VARCHAR(50) NOT NULL UNIQUE,
template_name VARCHAR(255) NOT NULL,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
-- Aplicación
applies_to_stage VARCHAR(50), -- cimentacion, estructura, instalaciones, acabados
partida VARCHAR(50), -- partida presupuestal
subpartida VARCHAR(50),
-- Items del checklist
items JSONB NOT NULL,
/* [{
itemId: "CHK-001",
section: "Cimentación",
question: "¿El armado cumple con el proyecto estructural?",
type: "boolean", // boolean, numeric, text, photo
isRequired: true,
hasTolerance: false,
tolerance: null,
minValue: null,
maxValue: null,
unit: null,
referenceValue: null,
helpText: "Verificar planos...",
requiresPhoto: true
}] */
total_items INTEGER,
-- No conformidades predefinidas
predefined_nc JSONB,
/* [{
ncCode: "NC-CIMENT-001",
description: "Armado incompleto",
severity: "major",
correctiveActionTemplate: "Completar armado según planos..."
}] */
-- Estado
is_active BOOLEAN DEFAULT true,
-- Metadata
created_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_checklist_templates_active ON evidence.checklist_templates(is_active) WHERE is_active = true;
CREATE INDEX idx_checklist_templates_stage ON evidence.checklist_templates(applies_to_stage);
-- =====================================================
-- TABLE: evidence.quality_checklists
-- Descripción: Checklists de calidad aplicados
-- =====================================================
CREATE TABLE evidence.quality_checklists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
unit_id UUID REFERENCES projects.units(id),
template_id UUID NOT NULL REFERENCES evidence.checklist_templates(id),
template_version INTEGER NOT NULL,
-- Código único
checklist_code VARCHAR(50) NOT NULL,
-- Contexto
partida VARCHAR(50),
subpartida VARCHAR(50),
stage_id UUID REFERENCES projects.stages(id),
-- Inspección
inspection_date DATE NOT NULL,
inspector_id UUID NOT NULL REFERENCES auth.users(id),
-- Items evaluados
items JSONB NOT NULL,
/* [{
itemId: "CHK-001",
question: "¿El armado cumple...?",
isCompliant: true,
value: null, // para numeric
measurement: 15.5,
tolerance: "±2cm",
notes: "Conforme a planos",
photoIds: ["uuid1", "uuid2"]
}] */
-- Resultados
total_items INTEGER NOT NULL,
compliant_items INTEGER DEFAULT 0,
compliance_percent DECIMAL(5,2) GENERATED ALWAYS AS (
CASE
WHEN total_items > 0 THEN (compliant_items::DECIMAL / total_items::DECIMAL) * 100
ELSE 0
END
) STORED,
-- No conformidades
non_conformities JSONB,
/* [{
ncId: "NC-001",
itemId: "CHK-003",
severity: "minor" | "major" | "critical",
description: "Descripción de la NC",
correctiveAction: "Acción correctiva propuesta",
responsibleId: "uuid",
dueDate: "2025-02-01",
status: "open" | "in_progress" | "closed",
closedDate: null,
closedBy: null,
verificationPhotoIds: []
}] */
total_nc INTEGER DEFAULT 0,
open_nc INTEGER DEFAULT 0,
closed_nc INTEGER DEFAULT 0,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- draft, completed, approved, rejected
approval_status VARCHAR(30),
-- approved, approved_with_observations, rejected
-- Firma digital
signature_data TEXT, -- Base64 de la firma
signed_by UUID REFERENCES auth.users(id),
signed_at TIMESTAMP,
-- PDF generado
pdf_generated BOOLEAN DEFAULT false,
pdf_path VARCHAR(512),
pdf_generated_at TIMESTAMP,
-- Notas generales
general_notes TEXT,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_status CHECK (status IN ('draft', 'completed', 'approved', 'rejected')),
CONSTRAINT valid_approval_status CHECK (approval_status IN ('approved', 'approved_with_observations', 'rejected'))
);
CREATE INDEX idx_checklists_project ON evidence.quality_checklists(project_id);
CREATE INDEX idx_checklists_unit ON evidence.quality_checklists(unit_id);
CREATE INDEX idx_checklists_template ON evidence.quality_checklists(template_id);
CREATE INDEX idx_checklists_inspector ON evidence.quality_checklists(inspector_id);
CREATE INDEX idx_checklists_status ON evidence.quality_checklists(status);
CREATE INDEX idx_checklists_date ON evidence.quality_checklists(inspection_date);
-- =====================================================
-- TABLE: evidence.photo_albums
-- Descripción: Álbumes para organizar fotos
-- =====================================================
CREATE TABLE evidence.photo_albums (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Relaciones
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
-- Identificación
album_code VARCHAR(50) NOT NULL,
album_name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo
album_type VARCHAR(30) NOT NULL,
-- project_progress, quality_inspection, incidents, final_delivery
-- Vinculación
stage_id UUID REFERENCES projects.stages(id),
unit_id UUID REFERENCES projects.units(id),
date_from DATE,
date_to DATE,
-- Cover
cover_photo_id UUID REFERENCES evidence.photos(id),
-- Metadata
created_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_album_type CHECK (album_type IN ('project_progress', 'quality_inspection', 'incidents', 'final_delivery'))
);
CREATE INDEX idx_albums_project ON evidence.photo_albums(project_id);
CREATE INDEX idx_albums_type ON evidence.photo_albums(album_type);
-- =====================================================
-- TABLE: evidence.album_photos
-- Descripción: Relación muchos a muchos entre álbumes y fotos
-- =====================================================
CREATE TABLE evidence.album_photos (
album_id UUID NOT NULL REFERENCES evidence.photo_albums(id) ON DELETE CASCADE,
photo_id UUID NOT NULL REFERENCES evidence.photos(id) ON DELETE CASCADE,
display_order INTEGER DEFAULT 0,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (album_id, photo_id)
);
CREATE INDEX idx_album_photos_album ON evidence.album_photos(album_id);
CREATE INDEX idx_album_photos_photo ON evidence.album_photos(photo_id);
4. TypeORM Entities
4.1 Photo Entity
// src/modules/evidence/entities/photo.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Project } from '../../projects/entities/project.entity';
import { Stage } from '../../projects/entities/stage.entity';
import { Unit } from '../../projects/entities/unit.entity';
import { User } from '../../auth/entities/user.entity';
export enum PhotoType {
PROGRESS = 'progress',
INCIDENT = 'incident',
FINAL = 'final',
QUALITY_CHECK = 'quality_check',
BEFORE_AFTER = 'before_after',
}
@Entity('photos', { schema: 'evidence' })
export class Photo {
@PrimaryGeneratedColumn('uuid')
id: string;
// Relaciones
@Column('uuid', { name: 'project_id' })
@Index()
projectId: string;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ type: 'uuid', nullable: true, name: 'stage_id' })
@Index()
stageId?: string;
@ManyToOne(() => Stage)
@JoinColumn({ name: 'stage_id' })
stage?: Stage;
@Column({ type: 'uuid', nullable: true, name: 'unit_id' })
@Index()
unitId?: string;
@ManyToOne(() => Unit)
@JoinColumn({ name: 'unit_id' })
unit?: Unit;
// Tipo
@Column({ type: 'enum', enum: PhotoType, name: 'photo_type' })
@Index()
photoType: PhotoType;
// Archivo
@Column({ type: 'varchar', length: 255, name: 'original_filename' })
originalFilename: string;
@Column({ type: 'varchar', length: 255, name: 'stored_filename' })
storedFilename: string;
@Column({ type: 'varchar', length: 512, name: 'file_path' })
filePath: string;
@Column({ type: 'integer', name: 'file_size' })
fileSize: number;
@Column({ type: 'varchar', length: 50, name: 'mime_type' })
mimeType: string;
// Thumbnail
@Column({ type: 'varchar', length: 512, nullable: true, name: 'thumbnail_path' })
thumbnailPath?: string;
@Column({ type: 'integer', nullable: true, name: 'thumbnail_size' })
thumbnailSize?: number;
// Dimensiones
@Column({ type: 'integer', nullable: true })
width?: number;
@Column({ type: 'integer', nullable: true })
height?: number;
@Column({ type: 'varchar', length: 20, nullable: true })
resolution?: string;
// Fechas
@Column({ type: 'timestamp', name: 'capture_date' })
captureDate: Date;
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'upload_date' })
uploadDate: Date;
// Marca de agua
@Column({ type: 'boolean', default: false, name: 'has_watermark' })
hasWatermark: boolean;
@Column({ type: 'text', nullable: true, name: 'watermark_text' })
watermarkText?: string;
// Georreferenciación
@Column({
type: 'geometry',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true,
})
@Index({ spatial: true })
geolocation?: string;
@Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'geo_accuracy' })
geoAccuracy?: number;
@Column({ type: 'boolean', default: false, name: 'geo_verified' })
geoVerified: boolean;
@Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'distance_from_site' })
distanceFromSite?: number;
// Descripción
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ type: 'varchar', array: true, default: '{}' })
tags: string[];
// Usuario
@Column({ type: 'uuid', name: 'uploaded_by' })
@Index()
uploadedBy: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'uploaded_by' })
uploader: User;
@Column({ type: 'varchar', length: 20, name: 'uploaded_via' })
uploadedVia: string;
// Dispositivo
@Column({ type: 'varchar', length: 100, nullable: true, name: 'device_model' })
deviceModel?: string;
@Column({ type: 'varchar', length: 50, nullable: true, name: 'device_os' })
deviceOs?: string;
// EXIF
@Column({ type: 'jsonb', nullable: true, name: 'exif_data' })
exifData?: any;
// Integridad
@Column({ type: 'varchar', length: 64, name: 'sha256_hash' })
sha256Hash: string;
@Column({ type: 'boolean', default: false, name: 'is_verified' })
isVerified: boolean;
// Soft delete
@Column({ type: 'boolean', default: false, name: 'is_deleted' })
@Index()
isDeleted: boolean;
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
deletedAt?: Date;
// Metadata
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
4.2 QualityChecklist Entity
// src/modules/evidence/entities/quality-checklist.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Project } from '../../projects/entities/project.entity';
import { Unit } from '../../projects/entities/unit.entity';
import { ChecklistTemplate } from './checklist-template.entity';
import { User } from '../../auth/entities/user.entity';
export enum ChecklistStatus {
DRAFT = 'draft',
COMPLETED = 'completed',
APPROVED = 'approved',
REJECTED = 'rejected',
}
export enum ApprovalStatus {
APPROVED = 'approved',
APPROVED_WITH_OBSERVATIONS = 'approved_with_observations',
REJECTED = 'rejected',
}
@Entity('quality_checklists', { schema: 'evidence' })
export class QualityChecklist {
@PrimaryGeneratedColumn('uuid')
id: string;
// Relaciones
@Column('uuid', { name: 'project_id' })
@Index()
projectId: string;
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ type: 'uuid', nullable: true, name: 'unit_id' })
@Index()
unitId?: string;
@ManyToOne(() => Unit)
@JoinColumn({ name: 'unit_id' })
unit?: Unit;
@Column('uuid', { name: 'template_id' })
@Index()
templateId: string;
@ManyToOne(() => ChecklistTemplate)
@JoinColumn({ name: 'template_id' })
template: ChecklistTemplate;
@Column({ type: 'integer', name: 'template_version' })
templateVersion: number;
// Código
@Column({ type: 'varchar', length: 50, name: 'checklist_code' })
checklistCode: string;
// Contexto
@Column({ type: 'varchar', length: 50, nullable: true })
partida?: string;
@Column({ type: 'varchar', length: 50, nullable: true })
subpartida?: string;
// Inspección
@Column({ type: 'date', name: 'inspection_date' })
@Index()
inspectionDate: Date;
@Column('uuid', { name: 'inspector_id' })
@Index()
inspectorId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'inspector_id' })
inspector: User;
// Items
@Column({ type: 'jsonb' })
items: any;
// Resultados
@Column({ type: 'integer', name: 'total_items' })
totalItems: number;
@Column({ type: 'integer', default: 0, name: 'compliant_items' })
compliantItems: number;
// No conformidades
@Column({ type: 'jsonb', nullable: true, name: 'non_conformities' })
nonConformities?: any;
@Column({ type: 'integer', default: 0, name: 'total_nc' })
totalNc: number;
@Column({ type: 'integer', default: 0, name: 'open_nc' })
openNc: number;
@Column({ type: 'integer', default: 0, name: 'closed_nc' })
closedNc: number;
// Estado
@Column({ type: 'enum', enum: ChecklistStatus, default: ChecklistStatus.DRAFT })
@Index()
status: ChecklistStatus;
@Column({ type: 'enum', enum: ApprovalStatus, nullable: true, name: 'approval_status' })
approvalStatus?: ApprovalStatus;
// Firma
@Column({ type: 'text', nullable: true, name: 'signature_data' })
signatureData?: string;
@Column({ type: 'uuid', nullable: true, name: 'signed_by' })
signedBy?: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'signed_by' })
signer?: User;
@Column({ type: 'timestamp', nullable: true, name: 'signed_at' })
signedAt?: Date;
// PDF
@Column({ type: 'boolean', default: false, name: 'pdf_generated' })
pdfGenerated: boolean;
@Column({ type: 'varchar', length: 512, nullable: true, name: 'pdf_path' })
pdfPath?: string;
@Column({ type: 'timestamp', nullable: true, name: 'pdf_generated_at' })
pdfGeneratedAt?: Date;
// Notas
@Column({ type: 'text', nullable: true, name: 'general_notes' })
generalNotes?: string;
// Metadata
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
// Computed
get compliancePercent(): number {
if (this.totalItems === 0) return 0;
return (this.compliantItems / this.totalItems) * 100;
}
}
5. Services (Lógica de Negocio)
5.1 PhotoService
// src/modules/evidence/services/photo.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Photo, PhotoType } from '../entities/photo.entity';
import { UploadPhotoDto } from '../dto';
import { StorageService } from './storage.service';
import { ImageProcessingService } from './image-processing.service';
import { ExifService } from './exif.service';
import { createHash } from 'crypto';
@Injectable()
export class PhotoService {
constructor(
@InjectRepository(Photo)
private photoRepo: Repository<Photo>,
private storageService: StorageService,
private imageService: ImageProcessingService,
private exifService: ExifService,
) {}
/**
* Subir foto con procesamiento completo
*/
async upload(
file: Express.Multer.File,
dto: UploadPhotoDto,
userId: string,
): Promise<Photo> {
// Calcular hash SHA256
const sha256Hash = createHash('sha256').update(file.buffer).digest('hex');
// Verificar duplicado
const existing = await this.photoRepo.findOne({
where: { sha256Hash, projectId: dto.projectId },
});
if (existing) {
throw new Error('Photo already uploaded (duplicate detected)');
}
// Extraer metadatos EXIF
const exifData = await this.exifService.extract(file.buffer);
// Obtener coordenadas GPS del EXIF (si existen)
let geolocation = dto.geolocation;
if (!geolocation && exifData.GPSLatitude && exifData.GPSLongitude) {
geolocation = {
type: 'Point',
coordinates: [exifData.GPSLongitude, exifData.GPSLatitude],
};
}
// Aplicar marca de agua
const watermarkText = dto.watermarkText || this.generateWatermark(dto);
const watermarkedBuffer = await this.imageService.applyWatermark(
file.buffer,
watermarkText,
);
// Generar thumbnail
const thumbnailBuffer = await this.imageService.createThumbnail(watermarkedBuffer);
// Subir a storage (S3, Google Cloud, etc.)
const timestamp = Date.now();
const storedFilename = `${timestamp}_${file.originalname}`;
const thumbnailFilename = `${timestamp}_thumb_${file.originalname}`;
const filePath = await this.storageService.upload(
watermarkedBuffer,
`projects/${dto.projectId}/photos/${storedFilename}`,
);
const thumbnailPath = await this.storageService.upload(
thumbnailBuffer,
`projects/${dto.projectId}/photos/thumbnails/${thumbnailFilename}`,
);
// Obtener dimensiones
const metadata = await this.imageService.getMetadata(watermarkedBuffer);
// Crear registro en BD
const photo = this.photoRepo.create({
projectId: dto.projectId,
stageId: dto.stageId,
unitId: dto.unitId,
activityId: dto.activityId,
photoType: dto.photoType || PhotoType.PROGRESS,
originalFilename: file.originalname,
storedFilename,
filePath,
fileSize: watermarkedBuffer.length,
mimeType: file.mimetype,
thumbnailPath,
thumbnailSize: thumbnailBuffer.length,
width: metadata.width,
height: metadata.height,
resolution: `${metadata.width}x${metadata.height}`,
captureDate: exifData.DateTime || new Date(),
hasWatermark: true,
watermarkText,
geolocation: geolocation ? JSON.stringify(geolocation) : null,
geoAccuracy: dto.geoAccuracy,
geoVerified: false, // Se verificará en background
description: dto.description,
tags: dto.tags || [],
uploadedBy: userId,
uploadedVia: dto.uploadedVia || 'web',
deviceModel: exifData.Model || dto.deviceModel,
deviceOs: dto.deviceOs,
exifData,
sha256Hash,
isVerified: true,
});
return this.photoRepo.save(photo);
}
/**
* Generar texto de marca de agua
*/
private generateWatermark(dto: UploadPhotoDto): string {
const project = dto.projectName || 'Proyecto';
const unit = dto.unitName || '';
const date = new Date().toLocaleDateString('es-MX');
const time = new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
return `${project}${unit ? ` | ${unit}` : ''} | ${date} ${time}`;
}
/**
* Obtener fotos de un proyecto
*/
async findByProject(
projectId: string,
filters?: {
photoType?: PhotoType;
stageId?: string;
unitId?: string;
dateFrom?: Date;
dateTo?: Date;
},
): Promise<Photo[]> {
const query = this.photoRepo
.createQueryBuilder('photo')
.where('photo.project_id = :projectId', { projectId })
.andWhere('photo.is_deleted = false');
if (filters?.photoType) {
query.andWhere('photo.photo_type = :photoType', { photoType: filters.photoType });
}
if (filters?.stageId) {
query.andWhere('photo.stage_id = :stageId', { stageId: filters.stageId });
}
if (filters?.unitId) {
query.andWhere('photo.unit_id = :unitId', { unitId: filters.unitId });
}
if (filters?.dateFrom) {
query.andWhere('photo.capture_date >= :dateFrom', { dateFrom: filters.dateFrom });
}
if (filters?.dateTo) {
query.andWhere('photo.capture_date <= :dateTo', { dateTo: filters.dateTo });
}
return query
.orderBy('photo.capture_date', 'DESC')
.getMany();
}
/**
* Soft delete de foto
*/
async softDelete(id: string, userId: string): Promise<void> {
const photo = await this.photoRepo.findOne({ where: { id } });
if (!photo) {
throw new NotFoundException(`Photo ${id} not found`);
}
photo.isDeleted = true;
photo.deletedAt = new Date();
photo.deletedBy = userId;
await this.photoRepo.save(photo);
}
}
5.2 ChecklistService
// src/modules/evidence/services/checklist.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { QualityChecklist, ChecklistStatus } from '../entities/quality-checklist.entity';
import { ChecklistTemplate } from '../entities/checklist-template.entity';
import { CreateChecklistDto, UpdateChecklistDto, SignChecklistDto } from '../dto';
import { PdfGenerationService } from './pdf-generation.service';
@Injectable()
export class ChecklistService {
constructor(
@InjectRepository(QualityChecklist)
private checklistRepo: Repository<QualityChecklist>,
@InjectRepository(ChecklistTemplate)
private templateRepo: Repository<ChecklistTemplate>,
private pdfService: PdfGenerationService,
) {}
/**
* Crear checklist desde template
*/
async create(dto: CreateChecklistDto, userId: string): Promise<QualityChecklist> {
const template = await this.templateRepo.findOne({
where: { id: dto.templateId },
});
if (!template) {
throw new Error('Template not found');
}
// Generar código
const year = new Date().getFullYear();
const count = await this.checklistRepo.count({ where: { projectId: dto.projectId } });
const code = `CHK-${year}-${String(count + 1).padStart(5, '0')}`;
// Inicializar items del template
const items = JSON.parse(JSON.stringify(template.items)).map((item: any) => ({
...item,
isCompliant: null,
value: null,
measurement: null,
notes: '',
photoIds: [],
}));
const checklist = this.checklistRepo.create({
projectId: dto.projectId,
unitId: dto.unitId,
templateId: template.id,
templateVersion: template.version,
checklistCode: code,
partida: dto.partida || template.partida,
subpartida: dto.subpartida || template.subpartida,
inspectionDate: dto.inspectionDate,
inspectorId: userId,
items,
totalItems: template.totalItems,
status: ChecklistStatus.DRAFT,
});
return this.checklistRepo.save(checklist);
}
/**
* Actualizar checklist (responder items)
*/
async update(id: string, dto: UpdateChecklistDto): Promise<QualityChecklist> {
const checklist = await this.checklistRepo.findOne({ where: { id } });
if (!checklist) {
throw new Error('Checklist not found');
}
if (dto.items) {
checklist.items = dto.items;
// Recalcular compliance
const compliantItems = dto.items.filter((item: any) => item.isCompliant === true).length;
checklist.compliantItems = compliantItems;
}
if (dto.nonConformities) {
checklist.nonConformities = dto.nonConformities;
checklist.totalNc = dto.nonConformities.length;
checklist.openNc = dto.nonConformities.filter((nc: any) => nc.status === 'open').length;
checklist.closedNc = dto.nonConformities.filter((nc: any) => nc.status === 'closed').length;
}
if (dto.generalNotes) {
checklist.generalNotes = dto.generalNotes;
}
return this.checklistRepo.save(checklist);
}
/**
* Completar y firmar checklist
*/
async sign(id: string, dto: SignChecklistDto, userId: string): Promise<QualityChecklist> {
const checklist = await this.checklistRepo.findOne({ where: { id } });
if (!checklist) {
throw new Error('Checklist not found');
}
checklist.status = ChecklistStatus.COMPLETED;
checklist.signatureData = dto.signatureData;
checklist.signedBy = userId;
checklist.signedAt = new Date();
const saved = await this.checklistRepo.save(checklist);
// Generar PDF en background
this.generatePdfAsync(saved.id);
return saved;
}
/**
* Generar PDF del checklist (asíncrono)
*/
private async generatePdfAsync(checklistId: string): Promise<void> {
try {
const checklist = await this.checklistRepo.findOne({
where: { id: checklistId },
relations: ['project', 'unit', 'template', 'inspector', 'signer'],
});
if (!checklist) return;
const pdfBuffer = await this.pdfService.generateChecklistPdf(checklist);
const pdfPath = await this.storageService.upload(
pdfBuffer,
`projects/${checklist.projectId}/checklists/${checklist.checklistCode}.pdf`,
);
checklist.pdfGenerated = true;
checklist.pdfPath = pdfPath;
checklist.pdfGeneratedAt = new Date();
await this.checklistRepo.save(checklist);
} catch (error) {
console.error('Error generating checklist PDF:', error);
}
}
}
6. Triggers y Stored Procedures
-- =====================================================
-- TRIGGER: Actualizar contadores de checklists
-- =====================================================
CREATE OR REPLACE FUNCTION evidence.update_checklist_counters()
RETURNS TRIGGER AS $$
BEGIN
-- Recalcular compliant_items desde items JSONB
IF NEW.items IS NOT NULL THEN
NEW.compliant_items := (
SELECT COUNT(*)
FROM jsonb_array_elements(NEW.items) AS item
WHERE (item->>'isCompliant')::BOOLEAN = true
);
END IF;
-- Recalcular NC counters desde non_conformities JSONB
IF NEW.non_conformities IS NOT NULL THEN
NEW.total_nc := jsonb_array_length(NEW.non_conformities);
NEW.open_nc := (
SELECT COUNT(*)
FROM jsonb_array_elements(NEW.non_conformities) AS nc
WHERE nc->>'status' = 'open'
);
NEW.closed_nc := (
SELECT COUNT(*)
FROM jsonb_array_elements(NEW.non_conformities) AS nc
WHERE nc->>'status' = 'closed'
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_checklist_counters
BEFORE INSERT OR UPDATE ON evidence.quality_checklists
FOR EACH ROW
EXECUTE FUNCTION evidence.update_checklist_counters();
7. Criterios de Aceptación Técnicos
- Schema
evidencecreado con tablas y relaciones - Upload de fotos con procesamiento completo
- Extracción de metadatos EXIF
- Marca de agua automática con Canvas
- Generación de thumbnails con Sharp
- Cálculo de hash SHA256 para integridad
- Georreferenciación con PostGIS
- Checklists con templates configurables
- Generación de PDFs con firma digital
- Soft delete para fotos
- Triggers para cálculos automáticos
- Tests unitarios >80% coverage
Fecha: 2025-11-17 Preparado por: Equipo Técnico Versión: 1.0 Estado: ✅ Listo para Implementación