Files
dh-inspeccion-v2/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts
T
admin 35d4630581 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
2026-09-08 23:18:15 -03:00

234 lines
8.6 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AssetHistoryService } from '../asset-master/asset-history.service';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetVersionChangeType, AuditAction } from '../database/entities';
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import { FieldInventoryService } from './field-inventory.service';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isOther: boolean;
};
type ParentRow = {
id: string;
typeCode: string;
inventoryFamilyId: string | null;
};
type FieldTypesBase = {
context: {
area: { id: string; code: string; name: string };
operatorCompany: { id: string; code: string; name: string };
inspection: { id: string; code: string; status: string };
};
parent: { id: string; code: string; name: string };
data: Array<Record<string, unknown> & { id: string; code: string; name: string }>;
};
const STRUCTURAL_CHILD: Record<string, string | undefined> = {
area: 'yacimiento',
yacimiento: 'instalacion',
instalacion: 'subinstalacion',
};
@Injectable()
export class F3FieldInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly fieldInventory: FieldInventoryService,
private readonly history: AssetHistoryService,
private readonly audit: AuditService,
) {}
async types(
visitId: string,
parentId: string | undefined,
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.context.area.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
? base.data.filter((type) => type.code.toLowerCase() === expectedTypeCode)
: [];
const families = await this.familiesFor(parent, expectedTypeCode);
return {
...base,
data: data.map((type) => ({
...type,
structuralKind: type.code.toUpperCase(),
families,
familyRequired: type.code.toLowerCase() === 'instalacion' || type.code.toLowerCase() === 'subinstalacion',
})),
};
}
async create(
visitId: string,
dto: CreateFieldInventoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const options = await this.types(visitId, dto.parentId, principal);
const selectedType = options.data.find((type) => type.id === dto.typeId);
if (!selectedType) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_STRUCTURE_TYPE_INVALID',
message: 'El tipo elegido no corresponde al siguiente nivel estructural permitido',
});
}
const typeCode = String(selectedType.code).toLowerCase();
const familyRequired = typeCode === 'instalacion' || typeCode === 'subinstalacion';
const families = (selectedType.families ?? []) as FamilyRow[];
if (familyRequired && !dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
});
}
if (!familyRequired && dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
message: 'Este nivel estructural no utiliza familia técnica',
});
}
const family = dto.familyId
? families.find((item) => item.id === dto.familyId)
: null;
if (dto.familyId && !family) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_INVALID',
message: 'La familia técnica no es válida para el padre seleccionado',
});
}
const created = await this.fieldInventory.create(visitId, dto, principal, request) as {
asset: { id: string; code: string; name: string };
[key: string]: unknown;
};
// Área/Yacimiento remain structural context. A concrete Installation/Subinstallation
// created in field is a real Inventory instance from the moment it is registered.
await this.dataSource.query(`
UPDATE assets
SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [
created.asset.id,
principal.userId,
typeCode === 'instalacion' || typeCode === 'subinstalacion',
]);
if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal);
await this.dataSource.transaction(async (manager) => {
const [current] = await manager.query(`
SELECT inventory_family_id AS "inventoryFamilyId"
FROM assets WHERE id=$1::uuid FOR UPDATE
`, [created.asset.id]) as Array<{ inventoryFamilyId: string | null }>;
if (!current) throw new ConflictException({
code: 'FIELD_INVENTORY_CREATED_ASSET_MISSING',
message: 'No se pudo clasificar el Inventario recién creado',
});
if (current.inventoryFamilyId === family.id) return;
await manager.query(`
UPDATE assets
SET inventory_family_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [created.asset.id, family.id, principal.userId]);
const versionNumber = await this.history.capture(
manager,
created.asset.id,
AssetVersionChangeType.UPDATED,
principal,
request,
);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_UPDATED,
entityType: 'asset',
entityId: created.asset.id,
beforeData: { inventoryFamilyId: current.inventoryFamilyId },
afterData: {
inventoryFamilyId: family.id,
inventoryFamilyCode: family.code,
inventoryFamilyName: family.name,
},
metadata: {
operation: 'FIELD_INVENTORY_FAMILY_ASSIGNED',
visitId,
versionNumber,
isOtherFamily: family.isOther,
},
}, manager);
});
return this.fieldInventory.detail(visitId, created.asset.id, principal);
}
private async parent(parentId: string): Promise<ParentRow> {
const rows = await this.dataSource.query(`
SELECT asset.id,type.code AS "typeCode",asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`, [parentId]) as ParentRow[];
if (!rows[0]) throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_NOT_AVAILABLE',
message: 'El padre estructural elegido no está disponible',
});
return rows[0];
}
private async familiesFor(parent: ParentRow, expectedTypeCode: string | undefined): Promise<FamilyRow[]> {
if (expectedTypeCode === 'instalacion') {
return this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels",
source_reference AS "sourceReference",
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_families
WHERE level='INSTALLATION' AND is_active=true
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
`) as Promise<FamilyRow[]>;
}
if (expectedTypeCode === 'subinstalacion') {
if (!parent.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
});
}
return this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_family_parent_rules rule
JOIN inventory_families family ON family.id=rule.child_family_id
WHERE rule.parent_family_id=$1::uuid
AND family.level='SUBINSTALLATION'
AND family.is_active=true
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
}
return [];
}
}