Files
dh-inspeccion-v2/api-v3/src/inspection-closing/inspection-closing.service.ts
T
admin cd24736df8 F1.1 · Inspección multi-Acta y firma diferida
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.
2026-09-05 14:46:26 -03:00

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',
});
}
}
}