Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia.
1451 lines
55 KiB
TypeScript
1451 lines
55 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
import { isAbsolute, parse, resolve } from 'node:path';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
|
import {
|
|
AuditAction,
|
|
InspectionAct,
|
|
InspectionActSignatureSource,
|
|
InspectionActSignatureStatus,
|
|
InspectionActSignerType,
|
|
InspectionCompanySignatureManifestation,
|
|
InspectionActStatus,
|
|
InspectionActUploadMode,
|
|
InspectionActVersionEvent,
|
|
InspectionResponsibleAttendanceStatus,
|
|
InspectionVisit,
|
|
InspectionVisitStatus,
|
|
} from '../database/entities';
|
|
import { sha256CanonicalJson } from './canonical-json';
|
|
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
|
|
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
|
|
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
|
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
|
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
|
|
import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
|
import {
|
|
inspectInspectionSignatureFile,
|
|
type UploadedInspectionSignatureFile,
|
|
} from './inspection-signature-file';
|
|
|
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2';
|
|
const CONSENT_VERSION = 'D5-1';
|
|
const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.';
|
|
const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.';
|
|
|
|
interface ResponsibleView {
|
|
actId: string;
|
|
attendanceStatus: InspectionResponsibleAttendanceStatus;
|
|
fullName: string | null;
|
|
documentType: string | null;
|
|
documentNumber: string | null;
|
|
position: string | null;
|
|
email: string | null;
|
|
phone: string | null;
|
|
absenceReason: string | null;
|
|
updatedBy: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
interface SignatureView {
|
|
id: string;
|
|
actId: string;
|
|
signerType: InspectionActSignerType;
|
|
signerUserId: string | null;
|
|
signerName: string;
|
|
documentType: string | null;
|
|
documentNumber: string | null;
|
|
position: string | null;
|
|
status: InspectionActSignatureStatus;
|
|
reason: string | null;
|
|
companyManifestation: InspectionCompanySignatureManifestation | null;
|
|
companyStatement: string | null;
|
|
mimeType: string | null;
|
|
sizeBytes: number | null;
|
|
imageSha256: string | null;
|
|
consentText: string | null;
|
|
consentVersion: string | null;
|
|
consentAcceptedAt: Date | null;
|
|
clientSignedAt: Date | null;
|
|
signedAt: Date | null;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
accuracyM: number | null;
|
|
deviceLabel: string | null;
|
|
source: InspectionActSignatureSource;
|
|
preparedSha256: string;
|
|
signaturePayloadSha256: string;
|
|
uploadedBy: string;
|
|
uploadedByUsername: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
interface StoredSignature extends SignatureView {
|
|
originalName: string | null;
|
|
storedName: string | null;
|
|
}
|
|
|
|
interface ClosureRecord {
|
|
actId: string;
|
|
schemaVersion: string;
|
|
preparedSnapshot: Record<string, unknown>;
|
|
preparedSha256: string;
|
|
preparedAt: Date;
|
|
preparedBy: string;
|
|
finalSnapshot: Record<string, unknown> | null;
|
|
finalSha256: string | null;
|
|
deviceClosedAt: Date | null;
|
|
serverClosedAt: Date | null;
|
|
uploadMode: InspectionActUploadMode | null;
|
|
closedBy: string | null;
|
|
}
|
|
|
|
export interface InspectionClosureView {
|
|
act: {
|
|
id: string;
|
|
code: string;
|
|
status: InspectionActStatus;
|
|
visitId: string;
|
|
currentVersion: number;
|
|
closedAt: Date | null;
|
|
closedBy: string | null;
|
|
closureSha256: string | null;
|
|
};
|
|
visit: {
|
|
id: string;
|
|
code: string;
|
|
status: InspectionVisitStatus;
|
|
actualClosedAt: Date | null;
|
|
};
|
|
responsible: ResponsibleView | null;
|
|
closure: null | {
|
|
schemaVersion: string;
|
|
preparedSha256: string;
|
|
preparedAt: Date;
|
|
preparedBy: string;
|
|
finalSha256: string | null;
|
|
deviceClosedAt: Date | null;
|
|
serverClosedAt: Date | null;
|
|
uploadMode: InspectionActUploadMode | null;
|
|
closedBy: string | null;
|
|
isCurrent: boolean;
|
|
};
|
|
signatures: SignatureView[];
|
|
consents: {
|
|
version: string;
|
|
inspector: string;
|
|
company: string;
|
|
};
|
|
}
|
|
|
|
function actNotFound(): NotFoundException {
|
|
return new NotFoundException({
|
|
code: 'INSPECTION_ACT_NOT_FOUND',
|
|
message: 'Acta de inspección no encontrada',
|
|
});
|
|
}
|
|
|
|
function signatureNotFound(): NotFoundException {
|
|
return new NotFoundException({
|
|
code: 'INSPECTION_SIGNATURE_NOT_FOUND',
|
|
message: 'Firma de inspección no encontrada',
|
|
});
|
|
}
|
|
|
|
@Injectable()
|
|
export class InspectionClosingService {
|
|
private readonly signatureRoot: string;
|
|
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
private readonly reports: InspectionReportsService,
|
|
config: ConfigService,
|
|
) {
|
|
const configured = config.get<string>('INSPECTION_SIGNATURE_ROOT')
|
|
?? '/app/storage/asset-media/inspection-signatures';
|
|
if (!isAbsolute(configured)) {
|
|
throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path');
|
|
}
|
|
this.signatureRoot = resolve(configured);
|
|
if (this.signatureRoot === parse(this.signatureRoot).root) {
|
|
throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root');
|
|
}
|
|
}
|
|
|
|
async get(actId: string): Promise<InspectionClosureView> {
|
|
return this.loadView(this.dataSource.manager, actId);
|
|
}
|
|
|
|
async upsertResponsible(
|
|
actId: string,
|
|
dto: UpsertInspectionResponsibleDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
this.assertDraftInProgress(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
|
const before = await this.loadResponsible(manager, actId);
|
|
const present = dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT;
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_responsibles (
|
|
act_id, attendance_status, full_name, document_type, document_number,
|
|
position, email, phone, absence_reason, updated_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
ON CONFLICT (act_id) DO UPDATE SET
|
|
attendance_status = EXCLUDED.attendance_status,
|
|
full_name = EXCLUDED.full_name,
|
|
document_type = EXCLUDED.document_type,
|
|
document_number = EXCLUDED.document_number,
|
|
position = EXCLUDED.position,
|
|
email = EXCLUDED.email,
|
|
phone = EXCLUDED.phone,
|
|
absence_reason = EXCLUDED.absence_reason,
|
|
updated_by = EXCLUDED.updated_by
|
|
`, [
|
|
actId,
|
|
dto.attendanceStatus,
|
|
present ? dto.fullName : null,
|
|
present ? dto.documentType : null,
|
|
present ? dto.documentNumber : null,
|
|
present ? dto.position : null,
|
|
dto.email?.toLowerCase() ?? null,
|
|
dto.phone ?? null,
|
|
present ? null : dto.absenceReason,
|
|
principal.userId,
|
|
]);
|
|
const after = await this.loadResponsible(manager, actId);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_RESPONSIBLE_UPDATED,
|
|
entityType: 'inspection_act_responsible',
|
|
entityId: actId,
|
|
beforeData: before as unknown as Record<string, unknown> | null,
|
|
afterData: after as unknown as Record<string, unknown>,
|
|
metadata: { actId, visitId: visit.id },
|
|
}, manager);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
}
|
|
|
|
async prepare(
|
|
actId: string,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
this.assertDraftInProgress(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
|
await this.requireResponsible(manager, actId);
|
|
const [count] = (await manager.query(`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM inspection_findings
|
|
WHERE act_id = $1 AND status <> 'VOIDED'
|
|
`, [actId])) as Array<{ total: number }>;
|
|
const [verification] = (await manager.query(`
|
|
SELECT
|
|
COUNT(*)::integer AS total,
|
|
COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed
|
|
FROM inspection_finding_verification_visits verification_link
|
|
WHERE verification_link.visit_id = $1
|
|
`, [visit.id])) as Array<{ total: number; completed: number }>;
|
|
const verificationTotal = Number(verification?.total ?? 0);
|
|
const verificationCompleted = Number(verification?.completed ?? 0);
|
|
if (verificationTotal > 0 && verificationCompleted !== verificationTotal) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
|
|
message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta',
|
|
});
|
|
}
|
|
if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_FINDING_REQUIRED',
|
|
message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse',
|
|
});
|
|
}
|
|
const signatures = await this.signatureCount(manager, actId);
|
|
if (signatures > 0) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_ALREADY_SIGNED',
|
|
message: 'El acta ya tiene firmas y no puede volver a prepararse',
|
|
});
|
|
}
|
|
const [updated] = (await manager.query(`
|
|
UPDATE inspection_acts
|
|
SET status = 'READY',
|
|
current_version = current_version + 1,
|
|
updated_by = $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING current_version AS "versionNumber"
|
|
`, [actId, principal.userId])) as Array<{ versionNumber: number }>;
|
|
const preparedAt = new Date();
|
|
const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt);
|
|
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_closures (
|
|
act_id, schema_version, prepared_snapshot, prepared_sha256,
|
|
prepared_at, prepared_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (act_id) DO UPDATE SET
|
|
schema_version = EXCLUDED.schema_version,
|
|
prepared_snapshot = EXCLUDED.prepared_snapshot,
|
|
prepared_sha256 = EXCLUDED.prepared_sha256,
|
|
prepared_at = EXCLUDED.prepared_at,
|
|
prepared_by = EXCLUDED.prepared_by
|
|
`, [
|
|
actId,
|
|
CLOSURE_SCHEMA_VERSION,
|
|
preparedSnapshot,
|
|
preparedSha256,
|
|
preparedAt,
|
|
principal.userId,
|
|
]);
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_versions (
|
|
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
actId,
|
|
Number(updated.versionNumber),
|
|
InspectionActVersionEvent.READY,
|
|
preparedSnapshot,
|
|
principal.userId,
|
|
principal.username,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_READY,
|
|
entityType: 'inspection_act',
|
|
entityId: actId,
|
|
afterData: {
|
|
status: InspectionActStatus.READY,
|
|
preparedSha256,
|
|
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
},
|
|
metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) },
|
|
}, manager);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
}
|
|
|
|
async reopen(
|
|
actId: string,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_READY',
|
|
message: 'Sólo puede reabrirse un acta preparada de una visita en curso',
|
|
});
|
|
}
|
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
|
if (await this.signatureCount(manager, actId)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN',
|
|
message: 'El acta ya tiene firmas y no puede volver a borrador',
|
|
});
|
|
}
|
|
const closure = await this.requireClosure(manager, actId);
|
|
const [updated] = (await manager.query(`
|
|
UPDATE inspection_acts
|
|
SET status = 'DRAFT',
|
|
current_version = current_version + 1,
|
|
updated_by = $2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING current_version AS "versionNumber"
|
|
`, [actId, principal.userId])) as Array<{ versionNumber: number }>;
|
|
const snapshot = {
|
|
actId,
|
|
actCode: act.code,
|
|
status: InspectionActStatus.DRAFT,
|
|
reopenedAt: new Date().toISOString(),
|
|
previousPreparedSha256: closure.preparedSha256,
|
|
};
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_versions (
|
|
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
actId,
|
|
Number(updated.versionNumber),
|
|
InspectionActVersionEvent.REOPENED,
|
|
snapshot,
|
|
principal.userId,
|
|
principal.username,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_REOPENED,
|
|
entityType: 'inspection_act',
|
|
entityId: actId,
|
|
afterData: snapshot,
|
|
metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) },
|
|
}, manager);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
}
|
|
|
|
async signInspector(
|
|
actId: string,
|
|
dto: CreateInspectionSignatureDto,
|
|
file: UploadedInspectionSignatureFile | undefined,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
const identity = {
|
|
signerType: InspectionActSignerType.INSPECTOR,
|
|
signerUserId: principal.userId,
|
|
signerName: `${principal.firstName} ${principal.lastName}`.trim(),
|
|
documentType: null,
|
|
documentNumber: null,
|
|
position: 'Inspector/a',
|
|
consentText: INSPECTOR_CONSENT,
|
|
auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
|
|
} as const;
|
|
return this.createSignedSignature(actId, dto, file, identity, principal, request, true);
|
|
}
|
|
|
|
async signCompany(
|
|
actId: string,
|
|
dto: CreateCompanySignatureDto,
|
|
file: UploadedInspectionSignatureFile | undefined,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
return this.createSignedSignature(actId, dto, file, null, principal, request, false);
|
|
}
|
|
|
|
async recordCompanyOutcome(
|
|
actId: string,
|
|
dto: CreateCompanyOutcomeDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
this.assertReadyForCompanyOutcome(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
|
const responsible = await this.requireResponsible(manager, actId);
|
|
if (
|
|
dto.status === InspectionActSignatureStatus.REFUSED
|
|
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT
|
|
) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE',
|
|
message: 'La negativa a firmar requiere que el responsable esté presente',
|
|
});
|
|
}
|
|
if (
|
|
dto.status === InspectionActSignatureStatus.ABSENT
|
|
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT
|
|
) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_COMPANY_ABSENCE_MISMATCH',
|
|
message: 'El resultado ausente requiere que el responsable se haya registrado como ausente',
|
|
});
|
|
}
|
|
const closure = await this.requireClosure(manager, actId);
|
|
if (await this.hasCompanyOutcome(manager, actId)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
|
|
message: 'La recepción de la empresa ya fue registrada y es inmutable',
|
|
});
|
|
}
|
|
const source = this.signatureSource(principal);
|
|
const createdAt = new Date();
|
|
const signerName = responsible.fullName ?? 'Responsable de la empresa no presente';
|
|
const payload = {
|
|
actId,
|
|
preparedSha256: closure.preparedSha256,
|
|
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
|
|
signerName,
|
|
documentType: responsible.documentType,
|
|
documentNumber: responsible.documentNumber,
|
|
position: responsible.position,
|
|
status: dto.status,
|
|
reason: dto.reason,
|
|
source,
|
|
uploadedBy: principal.userId,
|
|
createdAt: createdAt.toISOString(),
|
|
};
|
|
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
|
const id = randomUUID();
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_signatures (
|
|
id, act_id, signer_type, signer_user_id, signer_name,
|
|
document_type, document_number, position, status, reason,
|
|
source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at
|
|
) VALUES (
|
|
$1, $2, $3, NULL, $4, $5, $6, $7, $8, $9,
|
|
$10, $11, $12, $13, $14
|
|
)
|
|
`, [
|
|
id,
|
|
actId,
|
|
InspectionActSignerType.COMPANY_RESPONSIBLE,
|
|
signerName,
|
|
responsible.documentType,
|
|
responsible.documentNumber,
|
|
responsible.position,
|
|
dto.status,
|
|
dto.reason,
|
|
source,
|
|
closure.preparedSha256,
|
|
signaturePayloadSha256,
|
|
principal.userId,
|
|
createdAt,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_COMPANY_OUTCOME_RECORDED,
|
|
entityType: 'inspection_act_signature',
|
|
entityId: id,
|
|
afterData: payload,
|
|
metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 },
|
|
}, manager);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
}
|
|
|
|
async close(
|
|
actId: string,
|
|
dto: CloseInspectionActDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionClosureView> {
|
|
assertMobileInspector(principal);
|
|
const closed = await this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
this.assertReadyForFinalClosure(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
|
const closure = await this.requireClosure(manager, actId);
|
|
const signatures = await this.loadSignatures(manager, actId);
|
|
if (!signatures.some((item) => (
|
|
item.signerType === InspectionActSignerType.INSPECTOR
|
|
&& item.status === InspectionActSignatureStatus.SIGNED
|
|
))) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
|
|
message: 'Se requiere al menos una firma de inspector para cerrar el acta',
|
|
});
|
|
}
|
|
if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED',
|
|
message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa',
|
|
});
|
|
}
|
|
const serverClosedAt = new Date();
|
|
const deviceClosedAt = new Date(dto.clientClosedAt);
|
|
if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_ACT_INVALID_DEVICE_TIME',
|
|
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
|
|
});
|
|
}
|
|
const signatureSnapshot = signatures.map((item) => ({
|
|
id: item.id,
|
|
signerType: item.signerType,
|
|
signerUserId: item.signerUserId,
|
|
signerName: item.signerName,
|
|
documentType: item.documentType,
|
|
documentNumber: item.documentNumber,
|
|
position: item.position,
|
|
status: item.status,
|
|
reason: item.reason,
|
|
companyManifestation: item.companyManifestation,
|
|
companyStatement: item.companyStatement,
|
|
imageSha256: item.imageSha256,
|
|
consentText: item.consentText,
|
|
consentVersion: item.consentVersion,
|
|
consentAcceptedAt: item.consentAcceptedAt,
|
|
clientSignedAt: item.clientSignedAt,
|
|
signedAt: item.signedAt,
|
|
latitude: item.latitude,
|
|
longitude: item.longitude,
|
|
accuracyM: item.accuracyM,
|
|
deviceLabel: item.deviceLabel,
|
|
source: item.source,
|
|
preparedSha256: item.preparedSha256,
|
|
signaturePayloadSha256: item.signaturePayloadSha256,
|
|
uploadedBy: item.uploadedBy,
|
|
createdAt: item.createdAt,
|
|
}));
|
|
const finalSnapshot = {
|
|
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
preparedSha256: closure.preparedSha256,
|
|
preparedSnapshot: closure.preparedSnapshot,
|
|
signatures: signatureSnapshot,
|
|
closure: {
|
|
deviceClosedAt: deviceClosedAt.toISOString(),
|
|
serverClosedAt: serverClosedAt.toISOString(),
|
|
uploadMode: dto.uploadMode,
|
|
closedBy: principal.userId,
|
|
closedByUsername: principal.username,
|
|
},
|
|
};
|
|
const finalSha256 = sha256CanonicalJson(finalSnapshot);
|
|
await manager.query(`
|
|
UPDATE inspection_act_closures
|
|
SET final_snapshot = $2,
|
|
final_sha256 = $3,
|
|
device_closed_at = $4,
|
|
server_closed_at = $5,
|
|
upload_mode = $6,
|
|
closed_by = $7
|
|
WHERE act_id = $1
|
|
`, [
|
|
actId,
|
|
finalSnapshot,
|
|
finalSha256,
|
|
deviceClosedAt,
|
|
serverClosedAt,
|
|
dto.uploadMode,
|
|
principal.userId,
|
|
]);
|
|
const [updated] = (await manager.query(`
|
|
UPDATE inspection_acts
|
|
SET status = 'CLOSED',
|
|
closed_at = $2,
|
|
closed_by = $3,
|
|
closure_sha256 = $4,
|
|
current_version = current_version + 1,
|
|
updated_by = $3,
|
|
updated_at = $2
|
|
WHERE id = $1
|
|
RETURNING current_version AS "versionNumber"
|
|
`, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_versions (
|
|
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
actId,
|
|
Number(updated.versionNumber),
|
|
InspectionActVersionEvent.CLOSED,
|
|
finalSnapshot,
|
|
principal.userId,
|
|
principal.username,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_CLOSED,
|
|
entityType: 'inspection_act',
|
|
entityId: actId,
|
|
afterData: {
|
|
status: InspectionActStatus.CLOSED,
|
|
closureSha256: finalSha256,
|
|
serverClosedAt,
|
|
uploadMode: dto.uploadMode,
|
|
},
|
|
metadata: {
|
|
actId,
|
|
visitId: visit.id,
|
|
versionNumber: Number(updated.versionNumber),
|
|
findingsRemainOpen: true,
|
|
visitRemainsIndependent: true,
|
|
},
|
|
}, manager);
|
|
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
await this.reports.ensureWordForAct(actId);
|
|
return closed;
|
|
}
|
|
|
|
async signatureContent(signatureId: string): Promise<{
|
|
filePath: string;
|
|
signature: StoredSignature;
|
|
}> {
|
|
const signature = await this.loadStoredSignature(this.dataSource.manager, signatureId);
|
|
if (signature.status !== InspectionActSignatureStatus.SIGNED || !signature.storedName) {
|
|
throw signatureNotFound();
|
|
}
|
|
const filePath = resolve(this.signatureRoot, signature.storedName);
|
|
if (!filePath.startsWith(`${this.signatureRoot}/`)) {
|
|
throw new InternalServerErrorException({
|
|
code: 'INVALID_INSPECTION_SIGNATURE_STORAGE_PATH',
|
|
message: 'Ruta de almacenamiento de firma inválida',
|
|
});
|
|
}
|
|
try {
|
|
const fileStat = await stat(filePath);
|
|
if (!fileStat.isFile() || fileStat.size !== signature.sizeBytes) throw new Error('size mismatch');
|
|
} catch {
|
|
throw new InternalServerErrorException({
|
|
code: 'INSPECTION_SIGNATURE_FILE_MISSING',
|
|
message: 'El archivo físico de la firma no está disponible',
|
|
});
|
|
}
|
|
return { filePath, signature };
|
|
}
|
|
|
|
private async createSignedSignature(
|
|
actId: string,
|
|
dto: CreateInspectionSignatureDto | CreateCompanySignatureDto,
|
|
file: UploadedInspectionSignatureFile | undefined,
|
|
fixedIdentity: null | {
|
|
signerType: InspectionActSignerType;
|
|
signerUserId: string;
|
|
signerName: string;
|
|
documentType: null;
|
|
documentNumber: null;
|
|
position: string;
|
|
consentText: string;
|
|
auditAction: AuditAction;
|
|
},
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
requireSelfAssignment: boolean,
|
|
): Promise<InspectionClosureView> {
|
|
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
|
const inspected = inspectInspectionSignatureFile(file);
|
|
const id = randomUUID();
|
|
const storedName = `${id}.png`;
|
|
const filePath = resolve(this.signatureRoot, storedName);
|
|
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
|
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
|
|
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
|
try {
|
|
return await this.dataSource.transaction(async (manager) => {
|
|
const { act, visit } = await this.lockContext(manager, actId);
|
|
if (fixedIdentity) this.assertReadyInProgress(act, visit);
|
|
else this.assertReadyForCompanyOutcome(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment);
|
|
const closure = await this.requireClosure(manager, actId);
|
|
const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId);
|
|
if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT',
|
|
message: 'No puede registrarse firma de empresa cuando el responsable está ausente',
|
|
});
|
|
}
|
|
const identity = fixedIdentity ?? {
|
|
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
|
|
signerUserId: null,
|
|
signerName: responsible!.fullName!,
|
|
documentType: responsible!.documentType,
|
|
documentNumber: responsible!.documentNumber,
|
|
position: responsible!.position,
|
|
consentText: COMPANY_CONSENT,
|
|
auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
|
|
};
|
|
if (identity.signerType === InspectionActSignerType.INSPECTOR) {
|
|
const [existing] = (await manager.query(`
|
|
SELECT 1
|
|
FROM inspection_act_signatures
|
|
WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2
|
|
`, [actId, identity.signerUserId])) as unknown[];
|
|
if (existing) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED',
|
|
message: 'El inspector actual ya firmó esta acta',
|
|
});
|
|
}
|
|
} else if (await this.hasCompanyOutcome(manager, actId)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
|
|
message: 'La recepción de la empresa ya fue registrada y es inmutable',
|
|
});
|
|
}
|
|
const source = this.signatureSource(principal);
|
|
const signedAt = new Date();
|
|
const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null;
|
|
const companyDto = fixedIdentity ? null : dto as CreateCompanySignatureDto;
|
|
const companyManifestation = fixedIdentity
|
|
? null
|
|
: companyDto?.manifestation ?? InspectionCompanySignatureManifestation.CONFORMITY;
|
|
const companyStatement = companyManifestation === InspectionCompanySignatureManifestation.DISSENT
|
|
? companyDto?.statement?.trim() ?? null
|
|
: null;
|
|
const payload = {
|
|
actId,
|
|
preparedSha256: closure.preparedSha256,
|
|
signerType: identity.signerType,
|
|
signerUserId: identity.signerUserId,
|
|
signerName: identity.signerName,
|
|
documentType: identity.documentType,
|
|
documentNumber: identity.documentNumber,
|
|
position: identity.position,
|
|
status: InspectionActSignatureStatus.SIGNED,
|
|
companyManifestation,
|
|
companyStatement,
|
|
imageSha256,
|
|
consentText: identity.consentText,
|
|
consentVersion: CONSENT_VERSION,
|
|
consentAcceptedAt: signedAt.toISOString(),
|
|
clientSignedAt: clientSignedAt?.toISOString() ?? null,
|
|
signedAt: signedAt.toISOString(),
|
|
latitude: dto.latitude ?? null,
|
|
longitude: dto.longitude ?? null,
|
|
accuracyM: dto.accuracyM ?? null,
|
|
deviceLabel: dto.deviceLabel?.trim() || null,
|
|
source,
|
|
uploadedBy: principal.userId,
|
|
};
|
|
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_signatures (
|
|
id, act_id, signer_type, signer_user_id, signer_name,
|
|
document_type, document_number, position, status,
|
|
company_manifestation, company_statement,
|
|
original_name, stored_name, mime_type, size_bytes, image_sha256,
|
|
consent_text, consent_version, consent_accepted_at,
|
|
client_signed_at, signed_at, latitude, longitude, accuracy_m,
|
|
device_label, source, prepared_sha256, signature_payload_sha256,
|
|
uploaded_by, created_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
$6, $7, $8, 'SIGNED',
|
|
$9, $10,
|
|
$11, $12, $13, $14, $15,
|
|
$16, $17, $18,
|
|
$19, $20, $21, $22, $23,
|
|
$24, $25, $26, $27,
|
|
$28, $20
|
|
)
|
|
`, [
|
|
id,
|
|
actId,
|
|
identity.signerType,
|
|
identity.signerUserId,
|
|
identity.signerName,
|
|
identity.documentType,
|
|
identity.documentNumber,
|
|
identity.position,
|
|
companyManifestation,
|
|
companyStatement,
|
|
inspected.originalName,
|
|
storedName,
|
|
inspected.mimeType,
|
|
file!.buffer.length,
|
|
imageSha256,
|
|
identity.consentText,
|
|
CONSENT_VERSION,
|
|
signedAt,
|
|
clientSignedAt,
|
|
signedAt,
|
|
dto.latitude ?? null,
|
|
dto.longitude ?? null,
|
|
dto.accuracyM ?? null,
|
|
dto.deviceLabel?.trim() || null,
|
|
source,
|
|
closure.preparedSha256,
|
|
signaturePayloadSha256,
|
|
principal.userId,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: identity.auditAction,
|
|
entityType: 'inspection_act_signature',
|
|
entityId: id,
|
|
afterData: payload,
|
|
metadata: {
|
|
actId,
|
|
visitId: visit.id,
|
|
immutable: true,
|
|
signaturePayloadSha256,
|
|
},
|
|
}, manager);
|
|
return this.loadView(manager, actId);
|
|
});
|
|
} catch (error) {
|
|
await unlink(filePath).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async buildPreparedSnapshot(
|
|
manager: EntityManager,
|
|
actId: string,
|
|
preparedAt: Date,
|
|
): Promise<Record<string, unknown>> {
|
|
const [act] = (await manager.query(`
|
|
SELECT
|
|
act.id,
|
|
act.code,
|
|
act.act_year AS "actYear",
|
|
act.act_number AS "actNumber",
|
|
act.status,
|
|
act.occurred_at AS "occurredAt",
|
|
act.title,
|
|
act.summary,
|
|
act.observations,
|
|
act.current_version AS "currentVersion",
|
|
act.created_at AS "createdAt",
|
|
act.updated_at AS "updatedAt",
|
|
JSONB_BUILD_OBJECT(
|
|
'id', visit.id,
|
|
'code', visit.code,
|
|
'title', visit.title,
|
|
'objective', visit.objective,
|
|
'status', visit.status,
|
|
'scopeAssetId', visit.scope_asset_id,
|
|
'leadInspectorUserId', visit.lead_inspector_user_id,
|
|
'plannedStartAt', visit.planned_start_at,
|
|
'plannedEndAt', visit.planned_end_at,
|
|
'actualStartedAt', visit.actual_started_at,
|
|
'instructions', visit.instructions
|
|
) AS visit
|
|
FROM inspection_acts act
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
WHERE act.id = $1
|
|
`, [actId])) as Array<Record<string, unknown>>;
|
|
if (!act) throw actNotFound();
|
|
const responsible = await this.requireResponsible(manager, actId);
|
|
const team = await manager.query(`
|
|
SELECT
|
|
member.user_id AS "userId",
|
|
user_account.username,
|
|
user_account.first_name AS "firstName",
|
|
user_account.last_name AS "lastName",
|
|
user_account.email,
|
|
(visit.lead_inspector_user_id = member.user_id) AS "isLead"
|
|
FROM inspection_visit_members member
|
|
INNER JOIN inspection_visits visit ON visit.id = member.visit_id
|
|
INNER JOIN users user_account ON user_account.id = member.user_id
|
|
WHERE member.visit_id = $1 AND member.included = true
|
|
ORDER BY "isLead" DESC, user_account.username, member.user_id
|
|
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
|
const assets = await manager.query(`
|
|
SELECT
|
|
asset.id,
|
|
asset.code,
|
|
asset.name,
|
|
asset.common_name AS "commonName",
|
|
asset.description,
|
|
asset.parent_id AS "parentId",
|
|
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', company.id,
|
|
'code', company.code,
|
|
'name', company.name,
|
|
'commonName', company.common_name
|
|
) END AS "operatorCompany",
|
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', area.id,
|
|
'code', area.code,
|
|
'name', area.name,
|
|
'commonName', area.common_name
|
|
) END AS "operationalArea",
|
|
asset.information_status AS "informationStatus",
|
|
asset.current_version AS "currentVersion",
|
|
asset.data_origin AS "dataOrigin",
|
|
asset.source_name AS "sourceName",
|
|
asset.source_reference AS "sourceReference",
|
|
asset.source_observed_at AS "sourceObservedAt",
|
|
asset_type.id AS "typeId",
|
|
asset_type.code AS "typeCode",
|
|
asset_type.name AS "typeName",
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'definitionId', definition.id,
|
|
'code', definition.code,
|
|
'name', definition.name,
|
|
'dataType', definition.data_type,
|
|
'value', attribute_value.value
|
|
) ORDER BY definition.sort_order, definition.code, definition.id)
|
|
FROM asset_attribute_values attribute_value
|
|
INNER JOIN asset_attribute_definitions definition
|
|
ON definition.id = attribute_value.definition_id
|
|
WHERE attribute_value.asset_id = asset.id
|
|
), '[]'::jsonb) AS attributes,
|
|
CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'type', geometry.geometry_type,
|
|
'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
|
'source', geometry.source,
|
|
'accuracyM', geometry.accuracy_m,
|
|
'capturedAt', geometry.captured_at,
|
|
'deviceLabel', geometry.device_label
|
|
) END AS geometry
|
|
FROM inspection_act_assets link
|
|
INNER JOIN assets asset ON asset.id = link.asset_id
|
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
|
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
|
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
|
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
|
|
WHERE link.act_id = $1 AND link.included = true
|
|
ORDER BY asset.code, asset.id
|
|
`, [actId]) as Array<Record<string, unknown>>;
|
|
const findings = await manager.query(`
|
|
SELECT
|
|
finding.id,
|
|
finding.finding_number AS "findingNumber",
|
|
finding.code,
|
|
finding.status,
|
|
finding.asset_id AS "assetId",
|
|
finding.catalog_item_id AS "catalogItemId",
|
|
finding.title,
|
|
finding.description,
|
|
finding.legal_basis AS "legalBasis",
|
|
finding.glossary,
|
|
finding.catalog_revision AS "catalogRevision",
|
|
finding.suggested_severity AS "suggestedSeverity",
|
|
finding.severity,
|
|
finding.correction_due_on AS "correctionDueOn",
|
|
finding.company_response AS "companyResponse",
|
|
finding.company_response_received_on AS "companyResponseReceivedOn",
|
|
finding.company_committed_correction_on AS "companyCommittedCorrectionOn",
|
|
finding.next_control_on AS "nextControlOn",
|
|
finding.current_version AS "currentVersion",
|
|
JSONB_BUILD_OBJECT(
|
|
'code', catalog.code,
|
|
'sourceNumber', catalog.source_number,
|
|
'title', catalog.title,
|
|
'revision', catalog.revision,
|
|
'suggestedSeverity', finding.suggested_severity,
|
|
'categoryCode', category.code,
|
|
'categoryName', category.name
|
|
) AS catalog,
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'id', evidence.id,
|
|
'communicationId', evidence.communication_id,
|
|
'kind', evidence.kind,
|
|
'purpose', evidence.purpose,
|
|
'originalName', evidence.original_name,
|
|
'mimeType', evidence.mime_type,
|
|
'sizeBytes', evidence.size_bytes,
|
|
'sha256', evidence.sha256,
|
|
'title', evidence.title,
|
|
'description', evidence.description,
|
|
'capturedAt', evidence.captured_at,
|
|
'latitude', evidence.latitude,
|
|
'longitude', evidence.longitude,
|
|
'accuracyM', evidence.accuracy_m,
|
|
'deviceLabel', evidence.device_label,
|
|
'source', evidence.source,
|
|
'uploadedBy', evidence.uploaded_by,
|
|
'createdAt', evidence.created_at
|
|
) ORDER BY evidence.created_at, evidence.id)
|
|
FROM inspection_finding_evidence evidence
|
|
WHERE evidence.finding_id = finding.id
|
|
), '[]'::jsonb) AS evidence,
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'id', communication.id,
|
|
'direction', communication.direction,
|
|
'channel', communication.channel,
|
|
'type', communication.type,
|
|
'occurredAt', communication.occurred_at,
|
|
'subject', communication.subject,
|
|
'details', communication.details,
|
|
'contactName', communication.contact_name,
|
|
'contactEmail', communication.contact_email,
|
|
'createdBy', communication.created_by,
|
|
'createdAt', communication.created_at
|
|
) ORDER BY communication.occurred_at, communication.id)
|
|
FROM inspection_finding_communications communication
|
|
WHERE communication.finding_id = finding.id
|
|
), '[]'::jsonb) AS communications
|
|
FROM inspection_findings finding
|
|
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
|
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
|
WHERE finding.act_id = $1 AND finding.status <> 'VOIDED'
|
|
ORDER BY finding.finding_number, finding.id
|
|
`, [actId]) as Array<Record<string, unknown>>;
|
|
const verificationResults = await manager.query(`
|
|
SELECT
|
|
verification_link.finding_id AS "findingId",
|
|
finding.code AS "findingCode",
|
|
finding.title AS "findingTitle",
|
|
finding.description AS "findingDescription",
|
|
finding.asset_id AS "assetId",
|
|
asset.code AS "assetCode",
|
|
asset.name AS "assetName",
|
|
verification_link.target_control_on AS "targetControlOn",
|
|
verification_link.outcome,
|
|
verification_link.result_notes AS "resultNotes",
|
|
verification_link.verified_at AS "verifiedAt",
|
|
verification_link.result_recorded_at AS "resultRecordedAt",
|
|
verification_link.rescheduled_control_on AS "rescheduledControlOn",
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'id', evidence.id,
|
|
'kind', evidence.kind,
|
|
'purpose', evidence.purpose,
|
|
'originalName', evidence.original_name,
|
|
'mimeType', evidence.mime_type,
|
|
'sizeBytes', evidence.size_bytes,
|
|
'sha256', evidence.sha256,
|
|
'title', evidence.title,
|
|
'description', evidence.description,
|
|
'capturedAt', evidence.captured_at,
|
|
'latitude', evidence.latitude,
|
|
'longitude', evidence.longitude,
|
|
'accuracyM', evidence.accuracy_m,
|
|
'deviceLabel', evidence.device_label,
|
|
'source', evidence.source,
|
|
'uploadedBy', evidence.uploaded_by,
|
|
'createdAt', evidence.created_at
|
|
) ORDER BY evidence.created_at, evidence.id)
|
|
FROM inspection_finding_evidence evidence
|
|
WHERE evidence.finding_id = finding.id
|
|
AND evidence.verification_visit_id = $1
|
|
AND evidence.purpose = 'VERIFICATION'
|
|
), '[]'::jsonb) AS evidence
|
|
FROM inspection_finding_verification_visits verification_link
|
|
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
|
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
|
WHERE verification_link.visit_id = $1
|
|
ORDER BY finding.code
|
|
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
|
return {
|
|
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
preparedAt: preparedAt.toISOString(),
|
|
act,
|
|
responsible,
|
|
team,
|
|
assets,
|
|
findings,
|
|
verificationResults,
|
|
};
|
|
}
|
|
|
|
private async loadView(manager: EntityManager, actId: string): Promise<InspectionClosureView> {
|
|
const [context] = (await manager.query(`
|
|
SELECT
|
|
act.id,
|
|
act.code,
|
|
act.status,
|
|
act.visit_id AS "visitId",
|
|
act.current_version AS "currentVersion",
|
|
act.closed_at AS "closedAt",
|
|
act.closed_by AS "closedBy",
|
|
act.closure_sha256 AS "closureSha256",
|
|
visit.code AS "visitCode",
|
|
visit.status AS "visitStatus",
|
|
visit.actual_closed_at AS "visitActualClosedAt"
|
|
FROM inspection_acts act
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
WHERE act.id = $1
|
|
`, [actId])) as Array<{
|
|
id: string;
|
|
code: string;
|
|
status: InspectionActStatus;
|
|
visitId: string;
|
|
currentVersion: number;
|
|
closedAt: Date | null;
|
|
closedBy: string | null;
|
|
closureSha256: string | null;
|
|
visitCode: string;
|
|
visitStatus: InspectionVisitStatus;
|
|
visitActualClosedAt: Date | null;
|
|
}>;
|
|
if (!context) throw actNotFound();
|
|
const responsible = await this.loadResponsible(manager, actId);
|
|
const closure = await this.loadClosure(manager, actId);
|
|
const signatures = await this.loadSignatures(manager, actId);
|
|
return {
|
|
act: {
|
|
id: context.id,
|
|
code: context.code,
|
|
status: context.status,
|
|
visitId: context.visitId,
|
|
currentVersion: Number(context.currentVersion),
|
|
closedAt: context.closedAt,
|
|
closedBy: context.closedBy,
|
|
closureSha256: context.closureSha256,
|
|
},
|
|
visit: {
|
|
id: context.visitId,
|
|
code: context.visitCode,
|
|
status: context.visitStatus,
|
|
actualClosedAt: context.visitActualClosedAt,
|
|
},
|
|
responsible,
|
|
closure: closure ? {
|
|
schemaVersion: closure.schemaVersion,
|
|
preparedSha256: closure.preparedSha256,
|
|
preparedAt: closure.preparedAt,
|
|
preparedBy: closure.preparedBy,
|
|
finalSha256: closure.finalSha256,
|
|
deviceClosedAt: closure.deviceClosedAt,
|
|
serverClosedAt: closure.serverClosedAt,
|
|
uploadMode: closure.uploadMode,
|
|
closedBy: closure.closedBy,
|
|
isCurrent: context.status === InspectionActStatus.READY
|
|
|| context.status === InspectionActStatus.CLOSED,
|
|
} : null,
|
|
signatures,
|
|
consents: {
|
|
version: CONSENT_VERSION,
|
|
inspector: INSPECTOR_CONSENT,
|
|
company: COMPANY_CONSENT,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async loadResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView | null> {
|
|
const [row] = (await manager.query(`
|
|
SELECT
|
|
act_id AS "actId",
|
|
attendance_status AS "attendanceStatus",
|
|
full_name AS "fullName",
|
|
document_type AS "documentType",
|
|
document_number AS "documentNumber",
|
|
position,
|
|
email,
|
|
phone,
|
|
absence_reason AS "absenceReason",
|
|
updated_by AS "updatedBy",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
FROM inspection_act_responsibles
|
|
WHERE act_id = $1
|
|
`, [actId])) as ResponsibleView[];
|
|
return row ?? null;
|
|
}
|
|
|
|
private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> {
|
|
const responsible = await this.loadResponsible(manager, actId);
|
|
if (!responsible) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
|
|
message: 'Debe identificarse al responsable presente o documentar su ausencia',
|
|
});
|
|
}
|
|
return responsible;
|
|
}
|
|
|
|
private async loadClosure(manager: EntityManager, actId: string): Promise<ClosureRecord | null> {
|
|
const [row] = (await manager.query(`
|
|
SELECT
|
|
act_id AS "actId",
|
|
schema_version AS "schemaVersion",
|
|
prepared_snapshot AS "preparedSnapshot",
|
|
prepared_sha256 AS "preparedSha256",
|
|
prepared_at AS "preparedAt",
|
|
prepared_by AS "preparedBy",
|
|
final_snapshot AS "finalSnapshot",
|
|
final_sha256 AS "finalSha256",
|
|
device_closed_at AS "deviceClosedAt",
|
|
server_closed_at AS "serverClosedAt",
|
|
upload_mode AS "uploadMode",
|
|
closed_by AS "closedBy"
|
|
FROM inspection_act_closures
|
|
WHERE act_id = $1
|
|
`, [actId])) as ClosureRecord[];
|
|
return row ?? null;
|
|
}
|
|
|
|
private async requireClosure(manager: EntityManager, actId: string): Promise<ClosureRecord> {
|
|
const closure = await this.loadClosure(manager, actId);
|
|
if (!closure) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_PREPARED',
|
|
message: 'El acta todavía no fue preparada para firmas',
|
|
});
|
|
}
|
|
return closure;
|
|
}
|
|
|
|
private signatureSelect(): string {
|
|
return `
|
|
SELECT
|
|
signature.id,
|
|
signature.act_id AS "actId",
|
|
signature.signer_type AS "signerType",
|
|
signature.signer_user_id AS "signerUserId",
|
|
signature.signer_name AS "signerName",
|
|
signature.document_type AS "documentType",
|
|
signature.document_number AS "documentNumber",
|
|
signature.position,
|
|
signature.status,
|
|
signature.reason,
|
|
signature.company_manifestation AS "companyManifestation",
|
|
signature.company_statement AS "companyStatement",
|
|
signature.original_name AS "originalName",
|
|
signature.stored_name AS "storedName",
|
|
signature.mime_type AS "mimeType",
|
|
signature.size_bytes::integer AS "sizeBytes",
|
|
signature.image_sha256 AS "imageSha256",
|
|
signature.consent_text AS "consentText",
|
|
signature.consent_version AS "consentVersion",
|
|
signature.consent_accepted_at AS "consentAcceptedAt",
|
|
signature.client_signed_at AS "clientSignedAt",
|
|
signature.signed_at AS "signedAt",
|
|
signature.latitude::double precision AS latitude,
|
|
signature.longitude::double precision AS longitude,
|
|
signature.accuracy_m::double precision AS "accuracyM",
|
|
signature.device_label AS "deviceLabel",
|
|
signature.source,
|
|
signature.prepared_sha256 AS "preparedSha256",
|
|
signature.signature_payload_sha256 AS "signaturePayloadSha256",
|
|
signature.uploaded_by AS "uploadedBy",
|
|
uploader.username AS "uploadedByUsername",
|
|
signature.created_at AS "createdAt"
|
|
FROM inspection_act_signatures signature
|
|
INNER JOIN users uploader ON uploader.id = signature.uploaded_by
|
|
`;
|
|
}
|
|
|
|
private async loadSignatures(manager: EntityManager, actId: string): Promise<SignatureView[]> {
|
|
const rows = await manager.query(`
|
|
${this.signatureSelect()}
|
|
WHERE signature.act_id = $1
|
|
ORDER BY signature.signer_type, signature.created_at, signature.id
|
|
`, [actId]) as StoredSignature[];
|
|
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
|
|
}
|
|
|
|
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
|
|
const [row] = await manager.query(`
|
|
${this.signatureSelect()}
|
|
WHERE signature.id = $1
|
|
`, [id]) as StoredSignature[];
|
|
if (!row) throw signatureNotFound();
|
|
return row;
|
|
}
|
|
|
|
private async signatureCount(manager: EntityManager, actId: string): Promise<number> {
|
|
const [row] = (await manager.query(`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM inspection_act_signatures
|
|
WHERE act_id = $1
|
|
`, [actId])) as Array<{ total: number }>;
|
|
return Number(row?.total ?? 0);
|
|
}
|
|
|
|
private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise<boolean> {
|
|
const rows = await manager.query(`
|
|
SELECT 1
|
|
FROM inspection_act_signatures
|
|
WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE'
|
|
LIMIT 1
|
|
`, [actId]) as unknown[];
|
|
return rows.length > 0;
|
|
}
|
|
|
|
private async lockContext(
|
|
manager: EntityManager,
|
|
actId: string,
|
|
): Promise<{ act: InspectionAct; visit: InspectionVisit }> {
|
|
const act = await manager.getRepository(InspectionAct)
|
|
.createQueryBuilder('act')
|
|
.where('act.id = :actId', { actId })
|
|
.setLock('pessimistic_write')
|
|
.getOne();
|
|
if (!act) throw actNotFound();
|
|
const visit = await manager.getRepository(InspectionVisit)
|
|
.createQueryBuilder('visit')
|
|
.where('visit.id = :visitId', { visitId: act.visitId })
|
|
.setLock('pessimistic_write')
|
|
.getOne();
|
|
if (!visit) throw actNotFound();
|
|
return { act, visit };
|
|
}
|
|
|
|
private assertDraftInProgress(act: InspectionAct, visit: InspectionVisit): void {
|
|
if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_EDITABLE',
|
|
message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso',
|
|
});
|
|
}
|
|
}
|
|
|
|
private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void {
|
|
if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_READY',
|
|
message: 'El acta debe estar preparada y la visita en curso',
|
|
});
|
|
}
|
|
}
|
|
|
|
private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void {
|
|
if (act.status !== InspectionActStatus.READY
|
|
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY',
|
|
message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre',
|
|
});
|
|
}
|
|
}
|
|
|
|
private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void {
|
|
if (act.status !== InspectionActStatus.READY
|
|
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_READY',
|
|
message: 'El acta debe estar preparada para completar su cierre definitivo',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async assertActorAssigned(
|
|
manager: EntityManager,
|
|
visitId: string,
|
|
principal: AuthPrincipal,
|
|
allowManager: boolean,
|
|
): Promise<void> {
|
|
if (allowManager && principal.permissions.includes('inspections.manage')) return;
|
|
const rows = await manager.query(`
|
|
SELECT 1
|
|
FROM inspection_visit_members
|
|
WHERE visit_id = $1 AND user_id = $2 AND included = true
|
|
`, [visitId, principal.userId]) as unknown[];
|
|
if (rows.length === 0) {
|
|
throw new ForbiddenException({
|
|
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
|
|
message: 'Sólo un integrante asignado a la visita puede realizar esta firma',
|
|
});
|
|
}
|
|
}
|
|
|
|
private signatureSource(principal: AuthPrincipal): InspectionActSignatureSource {
|
|
return principal.transport === 'bearer'
|
|
? InspectionActSignatureSource.ANDROID
|
|
: InspectionActSignatureSource.WEB;
|
|
}
|
|
|
|
private validateCoordinates(
|
|
latitude: number | undefined,
|
|
longitude: number | undefined,
|
|
accuracyM: number | undefined,
|
|
): void {
|
|
const hasLatitude = latitude !== undefined;
|
|
const hasLongitude = longitude !== undefined;
|
|
if (hasLatitude !== hasLongitude) {
|
|
throw new BadRequestException({
|
|
code: 'INVALID_INSPECTION_SIGNATURE_COORDINATES',
|
|
message: 'Latitud y longitud deben informarse juntas',
|
|
});
|
|
}
|
|
if (accuracyM !== undefined && !hasLatitude) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_SIGNATURE_ACCURACY_WITHOUT_COORDINATES',
|
|
message: 'La precisión requiere coordenadas',
|
|
});
|
|
}
|
|
}
|
|
}
|