F5 · Inventario operativo, territorio y catálogo autorizado (#25)

* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

* fix(inventory): preserve custom catalog and apply authoritative universal findings

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
This commit is contained in:
2026-09-08 23:18:15 -03:00
committed by GitHub
parent 1dc3282055
commit 35d4630581
49 changed files with 3988 additions and 669 deletions
@@ -1,28 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
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 } from '../database/entities';
import type {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount: number;
findingCount: number;
findingItemIds: string[];
};
@Injectable()
export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data };
}
async findings(familyId: string) {
const [family] = await this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const family = await this.family(familyId, false);
const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
`, [familyId]);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
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
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
ownId:string|null,
):Promise<string|null> {
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre',
});
return null;
}
if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
});
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
});
return parentFamilyId;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
}