Files
dh-inspeccion-v2/api-v3/src/inspection-findings/finding-catalog.service.ts
T

902 lines
34 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import {
administrationAuditContext,
isUniqueViolation,
} from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
FindingCatalogItem,
FindingCatalogProposalStatus,
FindingCategory,
} from '../database/entities';
import type { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.dto';
import type { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
import type { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import type { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
import {
FindingCatalogProposalDecision,
type ReviewFindingCatalogProposalDto,
} from './dto/review-finding-catalog-proposal.dto';
import type { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
import type { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
export interface FindingCategoryAdminView {
id: string;
code: string;
name: string;
sortOrder: number;
isActive: boolean;
itemCount: number;
activeItemCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface FindingCatalogItemAdminView {
id: string;
categoryId: string;
categoryCode: string;
categoryName: string;
categoryActive: boolean;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
importNote: string | null;
suggestedSeverity: number | null;
revision: number;
isActive: boolean;
usageCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface CatalogSelectionItem {
id: string;
categoryId: string;
categoryName: string;
code: string;
sourceNumber: number;
title: string;
suggestedSeverity: number | null;
enabled: boolean;
}
export interface AssetSelectionItem extends CatalogSelectionItem {
typeDefaultEnabled: boolean;
assetOverride: boolean | null;
}
export interface AssetTypeSelectionView {
assetType: {
id: string;
code: string;
name: string;
operationalRole: string;
};
configured: boolean;
reason: string | null;
items: CatalogSelectionItem[];
}
export interface AssetSelectionView {
asset: {
id: string;
code: string;
name: string;
assetTypeId: string;
assetTypeCode: string;
assetTypeName: string;
};
typeConfigured: boolean;
typeReason: string | null;
items: AssetSelectionItem[];
}
function categoryNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATEGORY_NOT_FOUND',
message: 'Categoría de hallazgos no encontrada',
});
}
function itemNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
message: 'Tipo de hallazgo no encontrado',
});
}
function assetTypeNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_TYPE_NOT_FOUND',
message: 'Tipo de Inventario no encontrado',
});
}
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
function proposalNotFound(): NotFoundException {
return new NotFoundException({
code: 'FINDING_CATALOG_PROPOSAL_NOT_FOUND',
message: 'Propuesta de catálogo no encontrada',
});
}
function catalogConflict(): ConflictException {
return new ConflictException({
code: 'FINDING_CATALOG_DUPLICATE',
message: 'Ya existe ese código o número dentro de la categoría seleccionada',
});
}
@Injectable()
export class FindingCatalogService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(query: ListFindingCatalogQueryDto) {
const conditions = ['item.is_active = true', 'category.is_active = true'];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.categoryId) conditions.push(`category.id = ${add(query.categoryId)}`);
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
item.title ILIKE ${search}
OR COALESCE(item.legal_basis, '') ILIKE ${search}
OR COALESCE(item.glossary, '') ILIKE ${search}
)`);
}
const categories = await this.dataSource.query(`
SELECT id, code, name, sort_order AS "sortOrder"
FROM finding_categories
WHERE is_active = true
ORDER BY sort_order, name, id
`) as unknown[];
const items = await this.dataSource.query(`
SELECT
item.id,
item.category_id AS "categoryId",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.suggested_severity AS "suggestedSeverity",
item.revision,
category.name AS "categoryName"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE ${conditions.join(' AND ')}
ORDER BY category.sort_order, item.source_number, item.code
`, parameters) as unknown[];
return { categories, items };
}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const selection = await this.loadAssetSelection(this.dataSource.manager, assetId);
const enabledIds = selection.items.filter((item) => item.enabled).map((item) => item.id);
const filters = ['item.is_active = true', 'category.is_active = true'];
const params: unknown[] = [enabledIds];
if (query.categoryId) {
params.push(query.categoryId);
filters.push(`category.id = $${params.length}::uuid`);
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const search = `$${params.length}`;
filters.push(`(item.title ILIKE ${search} OR COALESCE(item.legal_basis, '') ILIKE ${search} OR COALESCE(item.glossary, '') ILIKE ${search})`);
}
filters.push('item.id = ANY($1::uuid[])');
const [categories, items] = await Promise.all([
this.dataSource.query(`
SELECT DISTINCT category.id, category.code, category.name, category.sort_order AS "sortOrder"
FROM finding_categories category
INNER JOIN finding_catalog_items item ON item.category_id = category.id
WHERE ${filters.join(' AND ')}
ORDER BY category.sort_order, category.name, category.id
`, params),
this.dataSource.query(`
SELECT
item.id,
item.category_id AS "categoryId",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.suggested_severity AS "suggestedSeverity",
item.revision,
category.name AS "categoryName"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE ${filters.join(' AND ')}
ORDER BY category.sort_order, item.source_number, item.code
`, params),
]);
return {
asset: selection.asset,
typeConfigured: selection.typeConfigured,
configurationReason: selection.typeReason,
categories,
items,
other: {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo sólo cuando el hallazgo no exista en el catálogo. Se enviará una propuesta a revisión de oficina.',
},
};
}
async listAdmin(): Promise<{
categories: FindingCategoryAdminView[];
items: FindingCatalogItemAdminView[];
}> {
const [categories, items] = await Promise.all([
this.dataSource.query(this.categoryQuery('')) as Promise<FindingCategoryAdminView[]>,
this.dataSource.query(this.itemQuery('')) as Promise<FindingCatalogItemAdminView[]>,
]);
return { categories, items };
}
async getAssetTypeSelection(assetTypeId: string): Promise<AssetTypeSelectionView> {
return this.loadAssetTypeSelection(this.dataSource.manager, assetTypeId);
}
async replaceAssetTypeSelection(
assetTypeId: string,
dto: ReplaceFindingCatalogSelectionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetTypeSelectionView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadAssetTypeSelection(manager, assetTypeId);
await this.validateActiveItemIds(manager, dto.enabledItemIds);
await manager.query('DELETE FROM finding_catalog_item_asset_types WHERE asset_type_id = $1', [assetTypeId]);
if (dto.enabledItemIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by)
SELECT value, $2::uuid, $3::uuid
FROM unnest($1::uuid[]) AS value
`, [dto.enabledItemIds, assetTypeId, principal.userId]);
}
await manager.query(`
INSERT INTO finding_catalog_asset_type_profiles (asset_type_id, reason, updated_by)
VALUES ($1, $2, $3)
ON CONFLICT (asset_type_id) DO UPDATE SET
reason = EXCLUDED.reason,
updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP
`, [assetTypeId, dto.reason, principal.userId]);
const after = await this.loadAssetTypeSelection(manager, assetTypeId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_TYPE_APPLICABILITY_UPDATED,
entityType: 'finding_catalog_asset_type_profile',
entityId: assetTypeId,
beforeData: {
configured: before.configured,
enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id),
},
afterData: {
configured: after.configured,
enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id),
reason: dto.reason,
},
}, manager);
return after;
});
}
async getAssetSelection(assetId: string): Promise<AssetSelectionView> {
return this.loadAssetSelection(this.dataSource.manager, assetId);
}
async replaceAssetSelection(
assetId: string,
dto: ReplaceFindingCatalogSelectionDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetSelectionView> {
return this.dataSource.transaction(async (manager) => {
const before = await this.loadAssetSelection(manager, assetId);
await this.validateActiveItemIds(manager, dto.enabledItemIds);
const validIds = new Set(before.items.map((item) => item.id));
const invalid = dto.enabledItemIds.find((id) => !validIds.has(id));
if (invalid) {
throw new BadRequestException({
code: 'FINDING_CATALOG_ITEM_INVALID',
message: 'La selección contiene un tipo de hallazgo inexistente o inactivo',
});
}
const enabled = new Set(dto.enabledItemIds);
await manager.query('DELETE FROM finding_catalog_asset_overrides WHERE asset_id = $1', [assetId]);
for (const item of before.items) {
const desired = enabled.has(item.id);
if (desired === item.typeDefaultEnabled) continue;
await manager.query(`
INSERT INTO finding_catalog_asset_overrides (
asset_id, catalog_item_id, is_enabled, reason, created_by, updated_by
) VALUES ($1, $2, $3, $4, $5, $5)
`, [assetId, item.id, desired, dto.reason, principal.userId]);
}
const after = await this.loadAssetSelection(manager, assetId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ASSET_SELECTION_UPDATED,
entityType: 'finding_catalog_asset_selection',
entityId: assetId,
beforeData: {
enabledItemIds: before.items.filter((item) => item.enabled).map((item) => item.id),
},
afterData: {
enabledItemIds: after.items.filter((item) => item.enabled).map((item) => item.id),
reason: dto.reason,
},
}, manager);
return after;
});
}
async listProposals(query: ListFindingCatalogProposalsQueryDto) {
const params: unknown[] = [];
const filters: string[] = [];
if (query.status) {
params.push(query.status);
filters.push(`proposal.status = $${params.length}`);
}
if (query.assetTypeId) {
params.push(query.assetTypeId);
filters.push(`proposal.asset_type_id = $${params.length}::uuid`);
}
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
return this.dataSource.query(`
SELECT
proposal.id,
proposal.finding_id AS "findingId",
finding.code AS "findingCode",
proposal.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
proposal.asset_type_id AS "assetTypeId",
asset_type.code AS "assetTypeCode",
asset_type.name AS "assetTypeName",
proposal.proposed_title AS "proposedTitle",
proposal.proposed_legal_basis AS "proposedLegalBasis",
proposal.proposed_severity AS "proposedSeverity",
proposal.description,
proposal.status,
proposal.resolved_catalog_item_id AS "resolvedCatalogItemId",
resolved.code AS "resolvedCatalogCode",
resolved.title AS "resolvedCatalogTitle",
proposal.office_notes AS "officeNotes",
proposal.reviewed_by AS "reviewedBy",
reviewer.username AS "reviewedByUsername",
proposal.reviewed_at AS "reviewedAt",
proposal.created_at AS "createdAt",
proposal.updated_at AS "updatedAt"
FROM finding_catalog_proposals proposal
INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id
INNER JOIN assets asset ON asset.id = proposal.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id
LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id
LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by
${where}
ORDER BY CASE proposal.status WHEN 'PENDING' THEN 0 ELSE 1 END,
proposal.created_at DESC,
proposal.id DESC
`, params) as Promise<unknown[]>;
}
async reviewProposal(
id: string,
dto: ReviewFindingCatalogProposalDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const [proposal] = await manager.query(`
SELECT id, status, asset_type_id AS "assetTypeId"
FROM finding_catalog_proposals
WHERE id = $1
FOR UPDATE
`, [id]) as Array<{ id: string; status: FindingCatalogProposalStatus; assetTypeId: string }>;
if (!proposal) throw proposalNotFound();
if (proposal.status !== FindingCatalogProposalStatus.PENDING) {
throw new ConflictException({
code: 'FINDING_CATALOG_PROPOSAL_ALREADY_REVIEWED',
message: 'La propuesta ya fue resuelta y conserva esa decisión en el historial',
});
}
if (dto.decision === FindingCatalogProposalDecision.MATCH) {
if (!dto.catalogItemId) {
throw new BadRequestException({
code: 'FINDING_CATALOG_PROPOSAL_CATALOG_REQUIRED',
message: 'Seleccioná el tipo de hallazgo del catálogo que corresponde',
});
}
await this.validateActiveItemIds(manager, [dto.catalogItemId]);
await manager.query(`
UPDATE finding_catalog_proposals SET
status = 'MATCHED',
resolved_catalog_item_id = $2,
office_notes = $3,
reviewed_by = $4,
reviewed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, dto.catalogItemId, dto.notes ?? null, principal.userId]);
const [profile] = await manager.query(`
SELECT asset_type_id FROM finding_catalog_asset_type_profiles WHERE asset_type_id = $1
`, [proposal.assetTypeId]) as unknown[];
if (profile) {
await manager.query(`
INSERT INTO finding_catalog_item_asset_types (catalog_item_id, asset_type_id, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (catalog_item_id, asset_type_id) DO NOTHING
`, [dto.catalogItemId, proposal.assetTypeId, principal.userId]);
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_PROPOSAL_MATCHED,
entityType: 'finding_catalog_proposal',
entityId: id,
afterData: { status: 'MATCHED', resolvedCatalogItemId: dto.catalogItemId, notes: dto.notes ?? null },
}, manager);
} else {
await manager.query(`
UPDATE finding_catalog_proposals SET
status = 'REJECTED',
office_notes = $2,
reviewed_by = $3,
reviewed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [id, dto.notes ?? null, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_PROPOSAL_REJECTED,
entityType: 'finding_catalog_proposal',
entityId: id,
afterData: { status: 'REJECTED', notes: dto.notes ?? null },
}, manager);
}
const [value] = await manager.query(`
SELECT
proposal.id,
proposal.finding_id AS "findingId",
finding.code AS "findingCode",
proposal.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
proposal.asset_type_id AS "assetTypeId",
asset_type.code AS "assetTypeCode",
asset_type.name AS "assetTypeName",
proposal.proposed_title AS "proposedTitle",
proposal.proposed_legal_basis AS "proposedLegalBasis",
proposal.proposed_severity AS "proposedSeverity",
proposal.description,
proposal.status,
proposal.resolved_catalog_item_id AS "resolvedCatalogItemId",
resolved.code AS "resolvedCatalogCode",
resolved.title AS "resolvedCatalogTitle",
proposal.office_notes AS "officeNotes",
proposal.reviewed_by AS "reviewedBy",
reviewer.username AS "reviewedByUsername",
proposal.reviewed_at AS "reviewedAt",
proposal.created_at AS "createdAt",
proposal.updated_at AS "updatedAt"
FROM finding_catalog_proposals proposal
INNER JOIN inspection_findings finding ON finding.id = proposal.finding_id
INNER JOIN assets asset ON asset.id = proposal.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = proposal.asset_type_id
LEFT JOIN finding_catalog_items resolved ON resolved.id = proposal.resolved_catalog_item_id
LEFT JOIN users reviewer ON reviewer.id = proposal.reviewed_by
WHERE proposal.id = $1
`, [id]) as Array<{ id: string }>;
if (!value) throw proposalNotFound();
return value;
});
}
async createCategory(
dto: CreateFindingCategoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCategoryAdminView> {
try {
return await this.dataSource.transaction(async (manager) => {
const category = manager.getRepository(FindingCategory).create({
code: dto.code,
name: dto.name,
sortOrder: dto.sortOrder,
isActive: true,
});
await manager.getRepository(FindingCategory).save(category);
const created = await this.loadCategory(manager, category.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATEGORY_CREATED,
entityType: 'finding_category',
entityId: category.id,
afterData: { ...created },
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
async updateCategory(
id: string,
dto: UpdateFindingCategoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCategoryAdminView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(FindingCategory);
const category = await repository.findOne({ where: { id } });
if (!category) throw categoryNotFound();
const before = await this.loadCategory(manager, id);
if (dto.name !== undefined) category.name = dto.name;
if (dto.sortOrder !== undefined) category.sortOrder = dto.sortOrder;
if (dto.isActive !== undefined) category.isActive = dto.isActive;
await repository.save(category);
const updated = await this.loadCategory(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATEGORY_UPDATED,
entityType: 'finding_category',
entityId: id,
beforeData: { ...before },
afterData: { ...updated },
}, manager);
return updated;
});
}
async createItem(
dto: CreateFindingCatalogItemDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCatalogItemAdminView> {
try {
return await this.dataSource.transaction(async (manager) => {
await this.requireCategory(manager, dto.categoryId);
const item = manager.getRepository(FindingCatalogItem).create({
categoryId: dto.categoryId,
code: dto.code,
sourceNumber: dto.sourceNumber,
title: dto.title,
legalBasis: dto.legalBasis ?? null,
glossary: dto.glossary ?? null,
importNote: dto.importNote ?? null,
suggestedSeverity: dto.suggestedSeverity ?? null,
revision: 1,
isActive: true,
});
await manager.getRepository(FindingCatalogItem).save(item);
const created = await this.loadItem(manager, item.id);
await this.captureVersion(manager, created, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ITEM_CREATED,
entityType: 'finding_catalog_item',
entityId: item.id,
afterData: this.itemAuditView(created),
metadata: { revision: created.revision, categoryId: created.categoryId },
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
async updateItem(
id: string,
dto: UpdateFindingCatalogItemDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingCatalogItemAdminView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
try {
return await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(FindingCatalogItem);
const item = await repository.createQueryBuilder('item')
.where('item.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!item) throw itemNotFound();
const before = await this.loadItem(manager, id);
if (dto.categoryId !== undefined) {
await this.requireCategory(manager, dto.categoryId);
item.categoryId = dto.categoryId;
}
if (dto.sourceNumber !== undefined) item.sourceNumber = dto.sourceNumber;
if (dto.title !== undefined) item.title = dto.title;
if (dto.legalBasis !== undefined) item.legalBasis = dto.legalBasis;
if (dto.glossary !== undefined) item.glossary = dto.glossary;
if (dto.importNote !== undefined) item.importNote = dto.importNote;
if (dto.suggestedSeverity !== undefined) item.suggestedSeverity = dto.suggestedSeverity;
if (dto.isActive !== undefined) item.isActive = dto.isActive;
item.revision += 1;
await repository.save(item);
const updated = await this.loadItem(manager, id);
await this.captureVersion(manager, updated, principal);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ITEM_UPDATED,
entityType: 'finding_catalog_item',
entityId: id,
beforeData: this.itemAuditView(before),
afterData: this.itemAuditView(updated),
metadata: { revision: updated.revision, categoryId: updated.categoryId },
}, manager);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw catalogConflict();
throw error;
}
}
private async loadAssetTypeSelection(manager: EntityManager, assetTypeId: string): Promise<AssetTypeSelectionView> {
const [assetType] = await manager.query(`
SELECT type.id, type.code, type.name, type.operational_role AS "operationalRole",
profile.reason, (profile.asset_type_id IS NOT NULL) AS configured
FROM asset_types type
LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = type.id
WHERE type.id = $1
`, [assetTypeId]) as Array<{
id: string; code: string; name: string; operationalRole: string; reason: string | null; configured: boolean;
}>;
if (!assetType) throw assetTypeNotFound();
const items = await manager.query(`
SELECT
item.id,
item.category_id AS "categoryId",
category.name AS "categoryName",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.suggested_severity AS "suggestedSeverity",
CASE
WHEN $2::boolean = false THEN true
ELSE mapping.id IS NOT NULL
END AS enabled
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
LEFT JOIN finding_catalog_item_asset_types mapping
ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $1
WHERE item.is_active = true AND category.is_active = true
ORDER BY category.sort_order, item.source_number, item.code
`, [assetTypeId, assetType.configured]) as CatalogSelectionItem[];
return {
assetType: {
id: assetType.id,
code: assetType.code,
name: assetType.name,
operationalRole: assetType.operationalRole,
},
configured: Boolean(assetType.configured),
reason: assetType.reason,
items,
};
}
private async loadAssetSelection(manager: EntityManager, assetId: string): Promise<AssetSelectionView> {
const [asset] = await manager.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.asset_type_id AS "assetTypeId",
type.code AS "assetTypeCode",
type.name AS "assetTypeName",
profile.reason AS "typeReason",
(profile.asset_type_id IS NOT NULL) AS "typeConfigured"
FROM assets asset
INNER JOIN asset_types type ON type.id = asset.asset_type_id
LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = asset.asset_type_id
WHERE asset.id = $1
`, [assetId]) as Array<{
id: string; code: string; name: string; assetTypeId: string; assetTypeCode: string; assetTypeName: string;
typeReason: string | null; typeConfigured: boolean;
}>;
if (!asset) throw assetNotFound();
const items = await manager.query(`
SELECT
item.id,
item.category_id AS "categoryId",
category.name AS "categoryName",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.suggested_severity AS "suggestedSeverity",
CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END AS "typeDefaultEnabled",
override.is_enabled AS "assetOverride",
COALESCE(
override.is_enabled,
CASE WHEN $3::boolean = false THEN true ELSE mapping.id IS NOT NULL END
) AS enabled
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
LEFT JOIN finding_catalog_item_asset_types mapping
ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = $2
LEFT JOIN finding_catalog_asset_overrides override
ON override.catalog_item_id = item.id AND override.asset_id = $1
WHERE item.is_active = true AND category.is_active = true
ORDER BY category.sort_order, item.source_number, item.code
`, [assetId, asset.assetTypeId, asset.typeConfigured]) as AssetSelectionItem[];
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
assetTypeId: asset.assetTypeId,
assetTypeCode: asset.assetTypeCode,
assetTypeName: asset.assetTypeName,
},
typeConfigured: Boolean(asset.typeConfigured),
typeReason: asset.typeReason,
items,
};
}
private async validateActiveItemIds(manager: EntityManager, ids: string[]): Promise<void> {
if (!ids.length) return;
const [row] = await manager.query(`
SELECT COUNT(*)::integer AS count
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
WHERE item.id = ANY($1::uuid[])
AND item.is_active = true
AND category.is_active = true
`, [ids]) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== ids.length) {
throw new BadRequestException({
code: 'FINDING_CATALOG_ITEM_INVALID',
message: 'La selección contiene un tipo de hallazgo inexistente o inactivo',
});
}
}
private async requireCategory(manager: EntityManager, id: string): Promise<FindingCategory> {
const category = await manager.getRepository(FindingCategory).findOne({ where: { id } });
if (!category) throw categoryNotFound();
return category;
}
private async loadCategory(manager: EntityManager, id: string): Promise<FindingCategoryAdminView> {
const [value] = await manager.query(this.categoryQuery('WHERE category.id = $1'), [id]) as FindingCategoryAdminView[];
if (!value) throw categoryNotFound();
return value;
}
private async loadItem(manager: EntityManager, id: string): Promise<FindingCatalogItemAdminView> {
const [value] = await manager.query(this.itemQuery('WHERE item.id = $1'), [id]) as FindingCatalogItemAdminView[];
if (!value) throw itemNotFound();
return value;
}
private async captureVersion(
manager: EntityManager,
item: FindingCatalogItemAdminView,
principal: AuthPrincipal,
): Promise<void> {
await manager.query(`
INSERT INTO finding_catalog_item_versions (
item_id, revision, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5)
`, [
item.id,
item.revision,
this.itemAuditView(item),
principal.userId,
principal.username,
]);
}
private itemAuditView(item: FindingCatalogItemAdminView): Record<string, unknown> {
return {
id: item.id,
categoryId: item.categoryId,
categoryCode: item.categoryCode,
categoryName: item.categoryName,
code: item.code,
sourceNumber: item.sourceNumber,
title: item.title,
legalBasis: item.legalBasis,
glossary: item.glossary,
importNote: item.importNote,
suggestedSeverity: item.suggestedSeverity,
revision: item.revision,
isActive: item.isActive,
};
}
private categoryQuery(where: string): string {
return `
SELECT
category.id,
category.code,
category.name,
category.sort_order AS "sortOrder",
category.is_active AS "isActive",
COUNT(item.id)::integer AS "itemCount",
COUNT(item.id) FILTER (WHERE item.is_active)::integer AS "activeItemCount",
category.created_at AS "createdAt",
category.updated_at AS "updatedAt"
FROM finding_categories category
LEFT JOIN finding_catalog_items item ON item.category_id = category.id
${where}
GROUP BY category.id
ORDER BY category.sort_order, category.name, category.id
`;
}
private itemQuery(where: string): string {
return `
SELECT
item.id,
item.category_id AS "categoryId",
category.code AS "categoryCode",
category.name AS "categoryName",
category.is_active AS "categoryActive",
item.code,
item.source_number AS "sourceNumber",
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.import_note AS "importNote",
item.suggested_severity AS "suggestedSeverity",
item.revision,
item.is_active AS "isActive",
(SELECT COUNT(*)::integer FROM inspection_findings finding
WHERE finding.catalog_item_id = item.id) AS "usageCount",
item.created_at AS "createdAt",
item.updated_at AS "updatedAt"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
${where}
ORDER BY category.sort_order, item.source_number, item.code
`;
}
}