chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,815 @@
|
||||
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,
|
||||
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;
|
||||
title: 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;
|
||||
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 visit.title 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.assertVisitHasNoAct(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 > 999999) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
|
||||
message: 'La numeración anual de actas agotó su rango disponible',
|
||||
});
|
||||
}
|
||||
const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`;
|
||||
const act = manager.getRepository(InspectionAct).create({
|
||||
visitId,
|
||||
actYear,
|
||||
actNumber,
|
||||
code,
|
||||
status: InspectionActStatus.DRAFT,
|
||||
occurredAt,
|
||||
title: dto.title,
|
||||
summary: dto.summary,
|
||||
observations: dto.observations ?? 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.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,
|
||||
'title', visit.title,
|
||||
'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.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 assertVisitHasNoAct(
|
||||
manager: EntityManager,
|
||||
visitId: string,
|
||||
): Promise<void> {
|
||||
const rows = (await manager.query(`
|
||||
SELECT code
|
||||
FROM inspection_acts
|
||||
WHERE visit_id = $1
|
||||
LIMIT 1
|
||||
`, [visitId])) as Array<{ code: string }>;
|
||||
if (rows.length > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_VISIT_ACT_ALREADY_EXISTS',
|
||||
message: 'La visita ya tiene un acta; cada visita admite una única acta',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 activo del acta debe estar incluido en la visita',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
'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,
|
||||
'title', visit.title,
|
||||
'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,
|
||||
currentVersion: act.currentVersion,
|
||||
cancellationReason: act.cancellationReason,
|
||||
closedAt: act.closedAt,
|
||||
closedBy: act.closedBy,
|
||||
closureSha256: act.closureSha256,
|
||||
assetIds: act.assets.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user