870 lines
30 KiB
TypeScript
870 lines
30 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
|
|
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
|
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_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 search_company.name ILIKE ${search}
|
|
OR 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(`EXISTS (
|
|
SELECT 1
|
|
FROM inspection_act_assets company_link
|
|
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
|
|
WHERE company_link.act_id = act.id
|
|
AND company_link.included = true
|
|
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
|
|
)`);
|
|
}
|
|
if (query.areaId) {
|
|
conditions.push(`EXISTS (
|
|
SELECT 1
|
|
FROM inspection_act_assets area_link
|
|
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
|
|
WHERE area_link.act_id = act.id
|
|
AND area_link.included = true
|
|
AND area_asset.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(`EXISTS (
|
|
SELECT 1 FROM inspection_act_assets company_link
|
|
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
|
|
WHERE company_link.act_id = act.id
|
|
AND company_link.included = true
|
|
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
|
|
)`);
|
|
}
|
|
if (query.areaId) {
|
|
conditions.push(`EXISTS (
|
|
SELECT 1 FROM inspection_act_assets area_link
|
|
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
|
|
WHERE area_link.act_id = act.id
|
|
AND area_link.included = true
|
|
AND area_asset.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
|
|
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
|
'id', company.id,
|
|
'code', company.code,
|
|
'name', company.name
|
|
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
|
|
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
|
'id', area.id,
|
|
'code', area.code,
|
|
'name', area.name
|
|
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
|
|
FROM inspection_act_assets context_link
|
|
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
|
|
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
|
|
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
|
|
WHERE context_link.act_id = act.id AND context_link.included = true
|
|
) 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> {
|
|
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 (assetIds.length < 1 || 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,
|
|
'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),
|
|
};
|
|
}
|
|
}
|