902 lines
34 KiB
TypeScript
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
|
|
`;
|
|
}
|
|
}
|