181 lines
13 KiB
TypeScript
181 lines
13 KiB
TypeScript
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
|
|
import { join, resolve } from 'path';
|
|
import { DataSource } from 'typeorm';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
|
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
|
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
|
|
|
export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024;
|
|
export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer }
|
|
|
|
const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses');
|
|
|
|
@Injectable()
|
|
export class ActAdministrationService {
|
|
constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {}
|
|
|
|
private baseCte() {
|
|
return `
|
|
WITH act_rows AS (
|
|
SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt",
|
|
ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode",
|
|
v.operational_area_id AS "areaId", v.operator_company_id AS "companyId",
|
|
area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName",
|
|
dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason",
|
|
dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn",
|
|
rsp.committed_correction_on AS "committedCorrectionOn",
|
|
COALESCE(fc.finding_count, 0)::int AS "findingCount",
|
|
COALESCE(fc.open_count, 0)::int AS "openFindingCount",
|
|
COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount"
|
|
FROM inspection_acts ia
|
|
JOIN inspection_visits v ON v.id = ia.visit_id
|
|
LEFT JOIN assets area ON area.id = v.operational_area_id
|
|
LEFT JOIN assets company ON company.id = v.operator_company_id
|
|
LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1
|
|
) dl ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1
|
|
) rsp ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count,
|
|
COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count,
|
|
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
|
|
FROM inspection_findings f WHERE f.act_id = ia.id
|
|
) fc ON true
|
|
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
|
), classified AS (
|
|
SELECT *, CASE
|
|
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
|
WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE'
|
|
WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING'
|
|
WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED'
|
|
WHEN "responseDueOn" IS NULL THEN 'NEW'
|
|
WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE'
|
|
WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON'
|
|
ELSE 'WAITING_RESPONSE'
|
|
END AS "adminState"
|
|
FROM act_rows
|
|
)`;
|
|
}
|
|
|
|
async queue(query: ListActAdministrationQueryDto) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 25;
|
|
const filters: string[] = [];
|
|
const args: unknown[] = [];
|
|
const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); };
|
|
if (query.areaId) add('"areaId" = ?', query.areaId);
|
|
if (query.companyId) add('"companyId" = ?', query.companyId);
|
|
if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state);
|
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
|
const rows = await this.dataSource.query(
|
|
`${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`,
|
|
[...args, pageSize, (page - 1) * pageSize],
|
|
) as Array<Record<string, unknown>>;
|
|
const total = Number(rows[0]?.totalRows ?? 0);
|
|
const counterFilters: string[] = [];
|
|
const counterArgs: unknown[] = [];
|
|
if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); }
|
|
if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); }
|
|
const countersRaw = await this.dataSource.query(
|
|
`${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`,
|
|
counterArgs,
|
|
) as Array<{ state: string; count: number }>;
|
|
const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)]));
|
|
return {
|
|
data: rows.map(({ totalRows: _ignored, ...row }) => row),
|
|
counters,
|
|
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
|
};
|
|
}
|
|
|
|
async detail(actId: string) {
|
|
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]);
|
|
const act = rows[0];
|
|
if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' });
|
|
const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]);
|
|
const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]);
|
|
const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]);
|
|
return { act, deadlines, responses, findings };
|
|
}
|
|
|
|
private async ensureClosedAct(actId: string) {
|
|
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
|
const act = rows[0];
|
|
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
|
if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
|
|
return act;
|
|
}
|
|
|
|
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
|
const act = await this.ensureClosedAct(actId);
|
|
const rows = await this.dataSource.query(
|
|
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
|
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
|
);
|
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
|
return rows[0];
|
|
}
|
|
|
|
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
|
const act = await this.ensureClosedAct(actId);
|
|
let storedName: string | null = null;
|
|
let sha256: string | null = null;
|
|
if (file) {
|
|
if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' });
|
|
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' });
|
|
await mkdir(STORAGE, { recursive: true });
|
|
sha256 = createHash('sha256').update(file.buffer).digest('hex');
|
|
storedName = `${randomUUID()}.pdf`;
|
|
await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' });
|
|
}
|
|
try {
|
|
const rows = await this.dataSource.query(
|
|
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
|
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
|
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
|
);
|
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
|
return rows[0];
|
|
} catch (error) {
|
|
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async responseContent(responseId: string) {
|
|
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
|
const row = rows[0];
|
|
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
|
const filePath = join(STORAGE, row.storedName);
|
|
const info = await stat(filePath).catch(() => null);
|
|
if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' });
|
|
return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath };
|
|
}
|
|
|
|
async calendar(from?: string, to?: string) {
|
|
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
|
|
if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' });
|
|
const start = from ?? new Date().toISOString().slice(0, 10);
|
|
const endDate = new Date(`${to ?? start}T00:00:00Z`);
|
|
if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60);
|
|
const end = endDate.toISOString().slice(0, 10);
|
|
if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' });
|
|
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]);
|
|
const events: Array<Record<string, unknown>> = [];
|
|
for (const row of rows) {
|
|
if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
|
if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
|
}
|
|
return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) };
|
|
}
|
|
}
|