Files
dh-inspeccion-v2/api-v3/src/inspection-acts/inspection-acts.service.ts
T
admin dea9358451 fix(android): Acta → Hallazgo → Inventario en campo
Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0.
2026-09-10 23:13:06 -03:00

837 lines
29 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
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,
DocumentSequenceType,
InspectionAct,
InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionActVersionEvent,
InspectionVisit,
InspectionVisitStatus,
} from '../database/entities';
import type { CancelInspectionActDto } from './dto/cancel-inspection-act.dto';
import type { CreateInspectionActDto } from './dto/create-inspection-act.dto';
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
import type { UpdateInspectionActDto } from './dto/update-inspection-act.dto';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
interface ActPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface ActAsset {
id: string;
code: string;
name: string;
typeName: string;
}
interface ActVisitSummary {
id: string;
code: string;
status: InspectionVisitStatus;
actualStartedAt: Date | null;
}
interface ActContextAsset {
id: string;
code: string;
name: string;
}
interface ActReportSummary {
id: string;
code: string;
status: string;
pdfStatus: string;
generatedAt: Date;
}
export interface InspectionActListItem {
id: string;
visitId: string;
visit: ActVisitSummary;
actYear: number;
actNumber: number;
code: string;
status: InspectionActStatus;
occurredAt: Date;
title: string;
summary: string;
observations: string | null;
urgency: InspectionActUrgency;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null;
deadlineAt: Date | null;
lockedAt: Date | null;
lockedBy: string | null;
lockedSha256: string | null;
sealedAt: Date | null;
sealedBy: string | null;
currentVersion: number;
cancellationReason: string | null;
closedAt: Date | null;
closedBy: string | null;
closureSha256: string | null;
assetCount: number;
findingCount: number;
companies: ActContextAsset[];
areas: ActContextAsset[];
report: ActReportSummary | null;
createdBy: ActPerson | null;
updatedBy: ActPerson | null;
createdAt: Date;
updatedAt: Date;
}
interface InspectionActVersionView {
id: string;
versionNumber: number;
event: InspectionActVersionEvent;
snapshot: Record<string, unknown>;
actorUserId: string | null;
actorUsername: string | null;
createdAt: Date;
}
export interface InspectionActView extends InspectionActListItem {
assets: ActAsset[];
versions: InspectionActVersionView[];
}
function actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
function visitNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
@Injectable()
export class InspectionActsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listGlobal(query: ListInspectionActsQueryDto) {
const conditions = ['1 = 1'];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search})
)
OR EXISTS (
SELECT 1 FROM assets search_company
WHERE search_company.id=visit.operator_company_id
AND search_company.name ILIKE ${search}
)
OR EXISTS (
SELECT 1 FROM assets search_area
WHERE search_area.id=visit.operational_area_id
AND search_area.name ILIKE ${search}
)
)`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = (await this.dataSource.query(
`${this.actSelect(where)}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}`,
parameters,
)) as InspectionActListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
await this.requireVisit(this.dataSource.manager, visitId);
const conditions = ['act.visit_id = $1'];
const parameters: unknown[] = [visitId];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = (await this.dataSource.query(
`${this.actSelect(where)}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}`,
parameters,
)) as InspectionActListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async getById(id: string): Promise<InspectionActView> {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async create(
visitId: string,
dto: CreateInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionActView> {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, visitId);
this.assertVisitOpen(visit);
await this.assertActorAssigned(manager, visitId, principal);
await this.assertVisitHasNoDraftAct(manager, visitId);
await this.assertVisitAssets(manager, visitId, dto.assetIds);
const occurredAt = new Date(dto.occurredAt);
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
const actNumber = await this.allocateNumber(manager, actYear);
if (actNumber > 99999) {
throw new ConflictException({
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de actas agotó su rango disponible',
});
}
const [dateRow] = (await manager.query(`
SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part
`, [occurredAt])) as Array<{ date_part: string }>;
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
const act = manager.getRepository(InspectionAct).create({
visitId,
actYear,
actNumber,
code,
status: InspectionActStatus.DRAFT,
occurredAt,
title: dto.title,
summary: dto.summary,
observations: dto.observations ?? null,
urgency: dto.urgency,
deadlineDays: null,
deadlineDayType: null,
deadlineBasis: null,
deadlineBaseAt: null,
deadlineAt: null,
lockedAt: null,
lockedBy: null,
lockedSha256: null,
sealedAt: null,
sealedBy: null,
currentVersion: 0,
cancellationReason: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(InspectionAct).save(act);
await this.replaceAssetLinks(manager, act.id, dto.assetIds, principal.userId);
const versionNumber = await this.captureVersion(
manager,
act,
InspectionActVersionEvent.CREATED,
principal,
);
const created = await this.loadView(manager, act.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_CREATED,
entityType: 'inspection_act',
entityId: act.id,
afterData: this.auditView(created),
metadata: { visitId, versionNumber },
}, manager);
return created;
});
}
async update(
id: string,
dto: UpdateInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionActView> {
assertMobileInspector(principal);
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const act = await this.lockAct(manager, id);
const visit = await this.lockVisit(manager, act.visitId);
this.assertVisitOpen(visit);
this.assertActDraft(act);
await this.assertActorAssigned(manager, visit.id, principal);
const nextOccurredAt = dto.occurredAt ? new Date(dto.occurredAt) : act.occurredAt;
const nextYear = await this.yearAtProjectTimezone(manager, nextOccurredAt);
if (nextYear !== act.actYear) {
throw new ConflictException({
code: 'INSPECTION_ACT_YEAR_LOCKED',
message: 'La fecha no puede cambiar el año de la numeración oficial asignada',
});
}
const nextAssetIds = dto.assetIds ?? await this.activeAssetIds(manager, id);
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
if (dto.urgency !== undefined) act.urgency = dto.urgency;
if (dto.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations;
act.updatedBy = principal.userId;
await manager.getRepository(InspectionAct).save(act);
if (dto.assetIds !== undefined) {
await this.replaceAssetLinks(manager, id, dto.assetIds, principal.userId);
}
const versionNumber = await this.captureVersion(
manager,
act,
InspectionActVersionEvent.UPDATED,
principal,
);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_UPDATED,
entityType: 'inspection_act',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: { visitId: visit.id, versionNumber },
}, manager);
return updated;
});
}
async cancel(
id: string,
dto: CancelInspectionActDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionActView> {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const act = await this.lockAct(manager, id);
const visit = await this.lockVisit(manager, act.visitId);
if (visit.status === InspectionVisitStatus.CLOSED) {
throw new ConflictException({
code: 'INSPECTION_VISIT_CLOSED',
message: 'No se puede cancelar un acta de una visita cerrada',
});
}
this.assertActDraft(act);
const before = await this.loadView(manager, id);
act.status = InspectionActStatus.CANCELLED;
act.cancellationReason = dto.reason;
act.updatedBy = principal.userId;
await manager.getRepository(InspectionAct).save(act);
const versionNumber = await this.captureVersion(
manager,
act,
InspectionActVersionEvent.CANCELLED,
principal,
);
const cancelled = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_ACT_CANCELLED,
entityType: 'inspection_act',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(cancelled),
metadata: { visitId: visit.id, versionNumber },
}, manager);
return cancelled;
});
}
private actSelect(where: string): string {
return `
SELECT
act.id,
act.visit_id AS "visitId",
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'status', visit.status,
'actualStartedAt', visit.actual_started_at
) AS visit,
act.act_year AS "actYear",
act.act_number AS "actNumber",
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
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_by AS "lockedBy",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.sealed_by AS "sealedBy",
act.current_version AS "currentVersion",
act.cancellation_reason AS "cancellationReason",
act.closed_at AS "closedAt",
act.closed_by AS "closedBy",
act.closure_sha256 AS "closureSha256",
COALESCE(asset_count.total, 0)::integer AS "assetCount",
COALESCE(finding_count.total, 0)::integer AS "findingCount",
COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas,
CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', report.id,
'code', report.code,
'status', report.status,
'pdfStatus', report.pdf_status,
'generatedAt', report.generated_at
) END AS report,
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', creator.id,
'username', creator.username,
'firstName', creator.first_name,
'lastName', creator.last_name
) END AS "createdBy",
CASE WHEN updater.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', updater.id,
'username', updater.username,
'firstName', updater.first_name,
'lastName', updater.last_name
) END AS "updatedBy",
act.created_at AS "createdAt",
act.updated_at AS "updatedAt"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
LEFT JOIN users creator ON creator.id = act.created_by
LEFT JOIN users updater ON updater.id = act.updated_by
LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL (
SELECT
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas
FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
WHERE context_visit.id=act.visit_id
) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM inspection_act_assets link
WHERE link.act_id = act.id AND link.included = true
) asset_count ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
) finding_count ON true
${where}
`;
}
private async loadView(manager: EntityManager, id: string): Promise<InspectionActView> {
const [act] = (await manager.query(
this.actSelect('WHERE act.id = $1'),
[id],
)) as InspectionActListItem[];
if (!act) throw actNotFound();
const assets = (await manager.query(`
SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName"
FROM inspection_act_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE link.act_id = $1 AND link.included = true
ORDER BY asset.code, asset.name
`, [id])) as ActAsset[];
const versions = (await manager.query(`
SELECT
version.id,
version.version_number AS "versionNumber",
version.event,
version.snapshot,
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.created_at AS "createdAt"
FROM inspection_act_versions version
WHERE version.act_id = $1
ORDER BY version.version_number DESC
`, [id])) as InspectionActVersionView[];
return { ...act, assets, versions };
}
private async requireVisit(manager: EntityManager, id: string): Promise<void> {
const rows = await manager.query('SELECT 1 FROM inspection_visits WHERE id = $1', [id]) as unknown[];
if (rows.length === 0) throw visitNotFound();
}
private async lockVisit(manager: EntityManager, id: string): Promise<InspectionVisit> {
const visit = await manager.getRepository(InspectionVisit)
.createQueryBuilder('visit')
.where('visit.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!visit) throw visitNotFound();
return visit;
}
private async lockAct(manager: EntityManager, id: string): Promise<InspectionAct> {
const act = await manager.getRepository(InspectionAct)
.createQueryBuilder('act')
.where('act.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!act) throw actNotFound();
return act;
}
private assertVisitOpen(visit: InspectionVisit): void {
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'La visita debe estar en curso para crear o editar actas',
});
}
}
private assertActDraft(act: InspectionAct): void {
if (act.status !== InspectionActStatus.DRAFT) {
throw new ConflictException({
code: 'INSPECTION_ACT_IMMUTABLE',
message: 'Sólo se puede editar o cancelar un acta en borrador',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const rows = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
`, [visitId, principal.userId]) as unknown[];
if (rows.length === 0) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La visita no está asignada al usuario actual',
});
}
}
private async assertVisitHasNoDraftAct(
manager: EntityManager,
visitId: string,
): Promise<void> {
const rows = (await manager.query(`
SELECT code
FROM inspection_acts
WHERE visit_id = $1 AND status = 'DRAFT'
LIMIT 1
`, [visitId])) as Array<{ code: string }>;
if (rows.length > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra',
});
}
}
private async assertVisitAssets(
manager: EntityManager,
visitId: string,
assetIds: string[],
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets
WHERE visit_id = $1
AND asset_id = ANY($2::uuid[])
AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección',
});
}
}
private async yearAtProjectTimezone(manager: EntityManager, value: Date): Promise<number> {
const [row] = (await manager.query(`
SELECT EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year
`, [value])) as Array<{ year: number }>;
return Number(row.year);
}
private async allocateNumber(manager: EntityManager, year: number): Promise<number> {
const [row] = (await manager.query(`
INSERT INTO document_annual_sequences (document_type, year, last_number)
VALUES ($1, $2, 1)
ON CONFLICT (document_type, year) DO UPDATE SET
last_number = document_annual_sequences.last_number + 1,
updated_at = CURRENT_TIMESTAMP
RETURNING last_number AS number
`, [DocumentSequenceType.ACT, year])) as Array<{ number: number }>;
return Number(row.number);
}
private async activeAssetIds(manager: EntityManager, actId: string): Promise<string[]> {
const rows = (await manager.query(`
SELECT asset_id AS id FROM inspection_act_assets
WHERE act_id = $1 AND included = true ORDER BY created_at, asset_id
`, [actId])) as Array<{ id: string }>;
return rows.map((row) => row.id);
}
private async replaceAssetLinks(
manager: EntityManager,
actId: string,
assetIds: string[],
userId: string,
): Promise<void> {
await manager.query(`
UPDATE inspection_act_assets
SET included = false, updated_at = CURRENT_TIMESTAMP
WHERE act_id = $1 AND included = true
`, [actId]);
for (const assetId of assetIds) {
await manager.query(`
INSERT INTO inspection_act_assets (act_id, asset_id, included, added_by)
VALUES ($1, $2, true, $3)
ON CONFLICT (act_id, asset_id) DO UPDATE SET
included = true,
added_by = EXCLUDED.added_by,
updated_at = CURRENT_TIMESTAMP
`, [actId, assetId, userId]);
}
}
private async captureVersion(
manager: EntityManager,
act: InspectionAct,
event: InspectionActVersionEvent,
principal: AuthPrincipal,
): Promise<number> {
const [row] = (await manager.query(`
UPDATE inspection_acts
SET current_version = current_version + 1
WHERE id = $1
RETURNING current_version AS "versionNumber"
`, [act.id])) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber);
act.currentVersion = versionNumber;
const snapshot = await this.buildSnapshot(manager, act.id);
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)
`, [act.id, versionNumber, event, snapshot, principal.userId, principal.username]);
return versionNumber;
}
private async buildSnapshot(
manager: EntityManager,
actId: string,
): Promise<Record<string, unknown>> {
const [row] = (await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id', act.id,
'code', act.code,
'actYear', act.act_year,
'actNumber', act.act_number,
'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,
'lockedAt', act.locked_at,
'lockedSha256', act.locked_sha256,
'sealedAt', act.sealed_at,
'currentVersion', act.current_version,
'cancellationReason', act.cancellation_reason,
'createdBy', act.created_by,
'updatedBy', act.updated_by,
'visit', JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'operationalAreaId', visit.operational_area_id,
'operatorCompanyId', visit.operator_company_id,
'actualStartedAt', visit.actual_started_at
),
'assets', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeId', asset.asset_type_id,
'typeName', asset_type.name,
'currentVersion', asset.current_version
) ORDER BY asset.code, asset.id)
FROM inspection_act_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE link.act_id = act.id AND link.included = true
), '[]'::jsonb)
) AS snapshot
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
`, [actId])) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw actNotFound();
return row.snapshot;
}
private auditView(act: InspectionActView): Record<string, unknown> {
return {
visitId: act.visitId,
code: act.code,
actYear: act.actYear,
actNumber: act.actNumber,
status: act.status,
occurredAt: act.occurredAt,
title: act.title,
summary: act.summary,
observations: act.observations,
urgency: act.urgency,
deadlineDays: act.deadlineDays,
deadlineDayType: act.deadlineDayType,
deadlineBasis: act.deadlineBasis,
deadlineBaseAt: act.deadlineBaseAt,
deadlineAt: act.deadlineAt,
lockedAt: act.lockedAt,
lockedSha256: act.lockedSha256,
sealedAt: act.sealedAt,
currentVersion: act.currentVersion,
cancellationReason: act.cancellationReason,
closedAt: act.closedAt,
closedBy: act.closedBy,
closureSha256: act.closureSha256,
assetIds: act.assets.map((asset) => asset.id),
};
}
}