# 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 ```typescript - 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 ```typescript - 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 ```typescript - 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 ```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 ```typescript // 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 ```typescript // 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 ```typescript // 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, 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 { // 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 { 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 { 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 ```typescript // 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, @InjectRepository(ChecklistTemplate) private templateRepo: Repository, private pdfService: PdfGenerationService, ) {} /** * Crear checklist desde template */ async create(dto: CreateChecklistDto, userId: string): Promise { 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 { 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 { 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 { 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 ```sql -- ===================================================== -- 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 - [x] Schema `evidence` creado con tablas y relaciones - [x] Upload de fotos con procesamiento completo - [x] Extracción de metadatos EXIF - [x] Marca de agua automática con Canvas - [x] Generación de thumbnails con Sharp - [x] Cálculo de hash SHA256 para integridad - [x] Georreferenciación con PostGIS - [x] Checklists con templates configurables - [x] Generación de PDFs con firma digital - [x] Soft delete para fotos - [x] Triggers para cálculos automáticos - [x] Tests unitarios >80% coverage --- **Fecha:** 2025-11-17 **Preparado por:** Equipo Técnico **Versión:** 1.0 **Estado:** ✅ Listo para Implementación