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, this.dataSource.query(this.itemQuery('')) as Promise, ]); return { categories, items }; } async getAssetTypeSelection(assetTypeId: string): Promise { return this.loadAssetTypeSelection(this.dataSource.manager, assetTypeId); } async replaceAssetTypeSelection( assetTypeId: string, dto: ReplaceFindingCatalogSelectionDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { 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 { return this.loadAssetSelection(this.dataSource.manager, assetId); } async replaceAssetSelection( assetId: string, dto: ReplaceFindingCatalogSelectionDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { 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; } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const category = await manager.getRepository(FindingCategory).findOne({ where: { id } }); if (!category) throw categoryNotFound(); return category; } private async loadCategory(manager: EntityManager, id: string): Promise { 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 { 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 { 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 { 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 `; } }