1242 lines
47 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}
|
|
}
|