Files
dh-inspeccion-v2/api-v3/src/inspection-closing/inspection-closing.service.ts
T

1242 lines
47 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 {
AuditAction,
InspectionAct,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
InspectionCompanySignatureManifestation,
InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionActUploadMode,
InspectionActVersionEvent,
InspectionResponsibleAttendanceStatus,
InspectionVisit,
InspectionVisitStatus,
} from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import { sha256CanonicalJson } from './canonical-json';
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-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-LIFECYCLE-V4';
const CONSENT_VERSION = 'F4-1';
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
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;
}
interface DeadlinePolicy {
urgentDays: number;
urgentDayType: InspectionDeadlineDayType;
nonUrgentDays: number;
nonUrgentDayType: InspectionDeadlineDayType;
}
export interface InspectionClosureView {
act: {
id: string;
code: string;
status: InspectionActStatus;
visitId: string;
urgency: InspectionActUrgency;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null;
deadlineAt: Date | null;
lockedAt: Date | null;
lockedSha256: string | null;
sealedAt: Date | null;
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,
updated_at=CURRENT_TIMESTAMP
`, [
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, inspectionId: visit.id },
}, manager);
return this.loadView(manager, actId);
});
}
/** Finaliza el contenido: desde este punto el Acta queda inmutable. */
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);
if (await this.signatureCount(manager, actId)) {
throw new ConflictException({
code: 'INSPECTION_ACT_ALREADY_SIGNED',
message: 'El acta ya tiene constancias de firma y no puede volver a finalizarse',
});
}
const policy = await this.deadlinePolicy(manager);
const deadlineDays = act.urgency === InspectionActUrgency.URGENT
? policy.urgentDays
: policy.nonUrgentDays;
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT
? policy.urgentDayType
: policy.nonUrgentDayType;
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT
? InspectionDeadlineBasis.ACT_DATE
: InspectionDeadlineBasis.GEDO_DATE;
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT
? act.occurredAt
: null;
const deadlineAt = deadlineBaseAt
? await this.calculateDeadline(manager, deadlineBaseAt, deadlineDays, deadlineDayType)
: null;
const lockedAt = new Date();
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status='LOCKED',
deadline_days=$2,
deadline_day_type=$3,
deadline_basis=$4,
deadline_base_at=$5,
deadline_at=$6,
locked_at=$7,
locked_by=$8,
current_version=current_version+1,
updated_by=$8,
updated_at=$7
WHERE id=$1
RETURNING current_version AS "versionNumber"
`, [
actId,
deadlineDays,
deadlineDayType,
deadlineBasis,
deadlineBaseAt,
deadlineAt,
lockedAt,
principal.userId,
])) as Array<{ versionNumber: number }>;
const preparedSnapshot = await this.buildLockedSnapshot(manager, actId, lockedAt);
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
await manager.query(`
UPDATE inspection_acts
SET locked_sha256=$2
WHERE id=$1
`, [actId, preparedSha256]);
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,
final_snapshot=NULL,
final_sha256=NULL,
device_closed_at=NULL,
server_closed_at=NULL,
upload_mode=NULL,
closed_by=NULL
`, [actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, lockedAt, 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.LOCKED,
preparedSnapshot,
principal.userId,
principal.username,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_LOCKED,
entityType: 'inspection_act',
entityId: actId,
afterData: {
status: InspectionActStatus.LOCKED,
lockedSha256: preparedSha256,
urgency: act.urgency,
deadlineDays,
deadlineDayType,
deadlineBasis,
deadlineBaseAt,
deadlineAt,
},
metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber) },
}, manager);
return this.loadView(manager, actId);
});
}
/**
* F4 elimina la reapertura normal: Finalizar Acta significa inmovilizar su contenido.
* Una eventual rectificación administrativa debe ser otro acto auditado, no volver a DRAFT.
*/
async reopen(
_actId: string,
_principal: AuthPrincipal,
_request: RequestWithContext,
): Promise<InspectionClosureView> {
throw new ConflictException({
code: 'INSPECTION_ACT_LOCK_IS_IMMUTABLE',
message: 'Un acta finalizada es inmutable y no puede volver a borrador',
});
}
async signInspector(
actId: string,
dto: CreateInspectionSignatureDto,
file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionClosureView> {
assertMobileInspector(principal);
return this.createSignedSignature(
actId,
dto,
file,
{
signerType: InspectionActSignerType.INSPECTOR,
signerUserId: principal.userId,
signerName: `${principal.firstName} ${principal.lastName}`.trim(),
documentType: null,
documentNumber: null,
position: 'Inspector/a',
consentText: INSPECTOR_CONSENT,
},
principal,
request,
);
}
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);
}
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.assertLockedForManifestation(act);
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 identificar al responsable que se negó',
});
}
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
message: 'La manifestación de la empresa ya fue registrada y es inmutable',
});
}
const closure = await this.requireClosure(manager, actId);
const createdAt = new Date();
const source = this.signatureSource(principal);
const signerName = responsible.fullName ?? 'Responsable no presente';
const payload = {
actId,
lockedSha256: closure.preparedSha256,
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerName,
documentType: responsible.documentType,
documentNumber: responsible.documentNumber,
position: responsible.position,
status: dto.status,
reason: dto.reason,
source,
recordedBy: 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, inspectionId: visit.id, immutable: true },
}, manager);
return this.loadView(manager, actId);
});
}
/**
* Endpoint legacy /close conservado durante la transición de contratos.
* En F4 su semántica es SELLAR, no volver editable ni cerrar la Inspección.
*/
async close(
actId: string,
dto: CloseInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionClosureView> {
assertMobileInspector(principal);
const sealed = await this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId);
this.assertLockedForManifestation(act);
await this.assertActorAssigned(manager, visit.id, principal, true);
const closure = await this.requireClosure(manager, actId);
const signatures = await this.loadSignatures(manager, actId);
const inspectorSigned = signatures.some((item) =>
item.signerType === InspectionActSignerType.INSPECTOR
&& item.status === InspectionActSignatureStatus.SIGNED,
);
if (!inspectorSigned) {
throw new ConflictException({
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
message: 'Se requiere la firma del inspector para sellar el acta',
});
}
const companyOutcomes = signatures.filter((item) =>
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
);
if (companyOutcomes.length !== 1) {
throw new ConflictException({
code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED',
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
});
}
const serverSealedAt = new Date();
const deviceSealedAt = new Date(dto.clientClosedAt);
if (deviceSealedAt.getTime() > serverSealedAt.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,
lockedSha256: closure.preparedSha256,
lockedSnapshot: closure.preparedSnapshot,
signatures: signatureSnapshot,
seal: {
deviceSealedAt: deviceSealedAt.toISOString(),
serverSealedAt: serverSealedAt.toISOString(),
uploadMode: dto.uploadMode,
sealedBy: principal.userId,
sealedByUsername: 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, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
const [updated] = (await manager.query(`
UPDATE inspection_acts
SET status='SEALED',
sealed_at=$2,
sealed_by=$3,
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, serverSealedAt, 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.SEALED,
finalSnapshot,
principal.userId,
principal.username,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_SEALED,
entityType: 'inspection_act',
entityId: actId,
afterData: {
status: InspectionActStatus.SEALED,
closureSha256: finalSha256,
sealedAt: serverSealedAt,
},
metadata: {
actId,
inspectionId: visit.id,
versionNumber: Number(updated.versionNumber),
findingsRemainIndependent: true,
inspectionRemainsIndependent: true,
},
}, manager);
await this.reports.ensureFrozenReport(manager, actId, principal, request);
return this.loadView(manager, actId);
});
await this.reports.ensureWordForAct(actId);
return sealed;
}
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;
},
principal: AuthPrincipal,
request: RequestWithContext,
): 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);
this.assertLockedForManifestation(act);
await this.assertActorAssigned(manager, visit.id, principal, true);
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 si no se identificó al responsable presente',
});
}
const identity = fixedIdentity ?? {
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerUserId: null,
signerName: responsible!.fullName!,
documentType: responsible!.documentType,
documentNumber: responsible!.documentNumber,
position: responsible!.position,
consentText: COMPANY_CONSENT,
};
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
LIMIT 1
`, [actId, identity.signerUserId]) as unknown[];
if (existing.length) {
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 manifestació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,
lockedSha256: 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: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
entityType: 'inspection_act_signature',
entityId: id,
afterData: payload,
metadata: { actId, inspectionId: visit.id, immutable: true },
}, manager);
return this.loadView(manager, actId);
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
private async buildLockedSnapshot(
manager: EntityManager,
actId: string,
lockedAt: Date,
): Promise<Record<string, unknown>> {
const [act] = await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id',act.id,
'code',act.code,
'status',act.status,
'occurredAt',act.occurred_at,
'title',act.title,
'summary',act.summary,
'observations',act.observations,
'urgency',act.urgency,
'deadlineDays',act.deadline_days,
'deadlineDayType',act.deadline_day_type,
'deadlineBasis',act.deadline_basis,
'deadlineBaseAt',act.deadline_base_at,
'deadlineAt',act.deadline_at,
'inspection',JSONB_BUILD_OBJECT(
'id',visit.id,
'code',visit.code,
'status',visit.status,
'operationalAreaId',visit.operational_area_id,
'operatorCompanyId',visit.operator_company_id,
'leadInspectorUserId',visit.lead_inspector_user_id,
'actualStartedAt',visit.actual_started_at
)
) AS act
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE act.id=$1
`, [actId]) as Array<{ act: Record<string, unknown> }>;
if (!act) throw actNotFound();
const responsible = await this.requireResponsible(manager, actId);
const inventories = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
type.code AS "typeCode",type.name AS "typeName"
FROM inspection_act_assets link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_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.severity,
finding.is_recurrence AS "isRecurrence",
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
finding.correction_due_on AS "correctionDueOn",
finding.current_version AS "currentVersion"
FROM inspection_findings finding
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id
`, [actId]) as Array<Record<string, unknown>>;
return {
schemaVersion: CLOSURE_SCHEMA_VERSION,
lockedAt: lockedAt.toISOString(),
act: act.act,
responsible,
inventories,
findings,
};
}
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
const [row] = await manager.query(`
SELECT
urgent_days AS "urgentDays",
urgent_day_type AS "urgentDayType",
non_urgent_days AS "nonUrgentDays",
non_urgent_day_type AS "nonUrgentDayType"
FROM inspection_deadline_policies
ORDER BY created_at
LIMIT 1
`) as DeadlinePolicy[];
return row ?? {
urgentDays: 5,
urgentDayType: InspectionDeadlineDayType.BUSINESS,
nonUrgentDays: 10,
nonUrgentDayType: InspectionDeadlineDayType.BUSINESS,
};
}
private async calculateDeadline(
manager: EntityManager,
baseAt: Date,
days: number,
dayType: InspectionDeadlineDayType,
): Promise<Date> {
if (dayType === InspectionDeadlineDayType.CALENDAR) {
const [row] = await manager.query(`
SELECT (
(($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp
+ time '23:59:59'
) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at
`, [baseAt, days]) as Array<{ due_at: Date }>;
return row.due_at;
}
const [row] = await manager.query(`
WITH candidates AS (
SELECT
day::date AS day,
COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business
FROM generate_series(
(($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp,
(($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp,
interval '1 day'
) day
LEFT JOIN inspection_business_calendar_days override ON override.date=day::date
), ranked AS (
SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position
FROM candidates
WHERE business=true
)
SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at
FROM ranked
WHERE position=$2
LIMIT 1
`, [baseAt, days]) as Array<{ due_at: Date }>;
if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento');
return row.due_at;
}
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.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
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
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;
urgency: InspectionActUrgency; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null;
lockedSha256: string | null; sealedAt: Date | null; 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,
urgency: context.urgency,
deadlineDays: context.deadlineDays,
deadlineDayType: context.deadlineDayType,
deadlineBasis: context.deadlineBasis,
deadlineBaseAt: context.deadlineBaseAt,
deadlineAt: context.deadlineAt,
lockedAt: context.lockedAt,
lockedSha256: context.lockedSha256,
sealedAt: context.sealedAt,
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: [InspectionActStatus.LOCKED, InspectionActStatus.SEALED].includes(context.status),
} : 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 o documentar su ausencia antes de finalizar el acta',
});
}
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_LOCKED',
message: 'El acta todavía no fue finalizada y bloqueada',
});
}
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
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 acta sólo puede editarse mientras está en borrador y la inspección está en curso',
});
}
}
private assertLockedForManifestation(act: InspectionAct): void {
if (act.status !== InspectionActStatus.LOCKED) {
throw new ConflictException({
code: 'INSPECTION_ACT_NOT_LOCKED',
message: 'El acta debe estar finalizada y bloqueada antes de firmarse o sellarse',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
inspectionId: 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
`, [inspectionId, principal.userId]) as unknown[];
if (!rows.length) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'Sólo un inspector asignado puede realizar esta operación',
});
}
}
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',
});
}
}
}