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:
@@ -29,8 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { InventoryFunctionController } from './inventory-function.controller';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||
import { InventoryBrowserService } from './inventory-browser.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
@@ -39,7 +39,7 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryFunctionController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
@@ -56,7 +56,7 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
@@ -71,7 +71,6 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
exports: [
|
||||
AssetHistoryService,
|
||||
AssetsService,
|
||||
InventoryFunctionService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
|
||||
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
|
||||
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
|
||||
FROM (
|
||||
SELECT relation.company_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operator_company_id AS company_id
|
||||
FROM assets asset
|
||||
WHERE asset.operational_area_id = $1
|
||||
AND asset.operator_company_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets company ON company.id = linked.company_id
|
||||
SELECT DISTINCT company.id, company.code, company.name,
|
||||
company.common_name AS "commonName", company_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets company ON company.id = relation.company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||
WHERE company.information_status <> 'INACTIVE'
|
||||
WHERE relation.area_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND company.information_status <> 'INACTIVE'
|
||||
ORDER BY company.name, company.code
|
||||
`, [areaId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
|
||||
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
|
||||
FROM (
|
||||
SELECT relation.area_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.company_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operational_area_id AS area_id
|
||||
FROM assets asset
|
||||
WHERE asset.operator_company_id = $1
|
||||
AND asset.operational_area_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets area ON area.id = linked.area_id
|
||||
SELECT DISTINCT area.id, area.code, area.name,
|
||||
area.common_name AS "commonName", area_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
WHERE area.information_status <> 'INACTIVE'
|
||||
WHERE relation.company_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND area.information_status <> 'INACTIVE'
|
||||
ORDER BY area.name, area.code
|
||||
`, [companyId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
|
||||
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||
}
|
||||
|
||||
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||
const [currentOperator] = (await manager.query(`
|
||||
SELECT relation.id,company.name AS "companyName"
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=$1
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND relation.company_id<>$2
|
||||
ORDER BY relation.valid_from DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF relation
|
||||
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||
if (currentOperator) {
|
||||
throw new ConflictException({
|
||||
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = (await manager.query(`
|
||||
INSERT INTO area_company_relations (
|
||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||
|
||||
// Compatibility snapshot only. This does not move or re-parent Inventory.
|
||||
// area_company_relations remains the temporal source of truth.
|
||||
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||
await manager.query(`
|
||||
UPDATE assets asset
|
||||
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
|
||||
FROM asset_types type
|
||||
WHERE type.id=asset.asset_type_id
|
||||
AND type.operational_role='GENERIC'
|
||||
AND asset.operational_area_id=$1::uuid
|
||||
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
|
||||
`,[dto.areaId,dto.companyId,principal.userId]);
|
||||
}
|
||||
|
||||
const created = await this.loadRelation(manager, row.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
|
||||
entityType: 'area_company_relation',
|
||||
entityId: row.id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
|
||||
: undefined,
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
|
||||
message: 'La relación ya se encuentra finalizada',
|
||||
});
|
||||
}
|
||||
if (before.assignedAssetCount > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'AREA_COMPANY_RELATION_IN_USE',
|
||||
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
||||
});
|
||||
}
|
||||
|
||||
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||
// Ending an operator relation must never be blocked by existing Inventory.
|
||||
await manager.query(`
|
||||
UPDATE area_company_relations
|
||||
SET valid_until = CURRENT_TIMESTAMP,
|
||||
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
|
||||
entityId: id,
|
||||
beforeData: this.auditView(before),
|
||||
afterData: this.auditView(updated),
|
||||
metadata: {
|
||||
inventoryHierarchyChanged:false,
|
||||
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||
},
|
||||
}, manager);
|
||||
return updated;
|
||||
});
|
||||
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
|
||||
(relation.valid_until IS NULL) AS active,
|
||||
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||
WHERE asset.operational_area_id = relation.area_id
|
||||
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
|
||||
AND asset.operator_company_id = relation.company_id
|
||||
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export const INVENTORY_STRUCTURE_KINDS = [
|
||||
'EMPRESA',
|
||||
'AREA',
|
||||
'YACIMIENTO',
|
||||
'INSTALACION',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
|
||||
|
||||
export class InventoryBrowserQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
typeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetInformationStatus)
|
||||
status?: AssetInformationStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetOperationalStatus)
|
||||
operationalStatus?: AssetOperationalStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operationalAreaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operatorCompanyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
needsValidation?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
hasGeometry?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 25;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateInventoryFamilyDto {
|
||||
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
|
||||
level!: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(240)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentFamilyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
informationLabels?: string[];
|
||||
}
|
||||
|
||||
export class UpdateInventoryFamilyDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(240)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentFamilyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
informationLabels?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ReplaceInventoryFamilyFindingsDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2000)
|
||||
@IsUUID('4', { each: true })
|
||||
itemIds!: string[];
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
hasGeometry?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
inventoryOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
|
||||
@IsEnum(AssetInformationStatus)
|
||||
status?: AssetInformationStatus;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetOperationalStatus)
|
||||
operationalStatus?: AssetOperationalStatus;
|
||||
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
|
||||
@IsBoolean()
|
||||
hasGeometry?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
inventoryOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentId?: string;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
|
||||
import { InventoryBrowserService } from './inventory-browser.service';
|
||||
|
||||
@Controller('inventory-browser')
|
||||
@RequirePermissions('assets.read')
|
||||
export class InventoryBrowserController {
|
||||
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
|
||||
|
||||
@Get('items')
|
||||
items(@Query() query: InventoryBrowserQueryDto) {
|
||||
return this.inventoryBrowser.items(query);
|
||||
}
|
||||
|
||||
@Get('areas')
|
||||
areas(@Query() query: InventoryBrowserQueryDto) {
|
||||
return this.inventoryBrowser.areas(query);
|
||||
}
|
||||
|
||||
@Get(':parentId/children')
|
||||
children(
|
||||
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
|
||||
@Query() query: InventoryBrowserQueryDto,
|
||||
) {
|
||||
return this.inventoryBrowser.children(parentId, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
|
||||
|
||||
type ParentContext = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InventoryBrowserService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async items(query: InventoryBrowserQueryDto) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [
|
||||
'asset.is_inventory_instance=true',
|
||||
"asset.information_status<>'INACTIVE'",
|
||||
'type.is_active=true',
|
||||
];
|
||||
const add = (value: unknown): string => {
|
||||
params.push(value);
|
||||
return `$${params.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const p = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
|
||||
}
|
||||
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
|
||||
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
|
||||
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
|
||||
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
|
||||
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
|
||||
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
|
||||
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
|
||||
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
|
||||
if (query.operatorCompanyId) {
|
||||
const company = add(query.operatorCompanyId);
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM area_company_relations relation
|
||||
WHERE relation.area_id=asset.operational_area_id
|
||||
AND relation.company_id=${company}::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
)`);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const [countRow] = (await this.dataSource.query(`
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE ${where}
|
||||
`,params)) as Array<{total:number}>;
|
||||
const total=Number(countRow?.total ?? 0);
|
||||
const offset=(query.page-1)*query.pageSize;
|
||||
params.push(query.pageSize);
|
||||
const limit=`$${params.length}`;
|
||||
params.push(offset);
|
||||
const offsetParam=`$${params.length}`;
|
||||
|
||||
const data=await this.dataSource.query(`
|
||||
SELECT
|
||||
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',parent.id,'code',parent.code,'name',parent.name
|
||||
) END AS parent,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
) END AS "operationalArea",
|
||||
(
|
||||
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=asset.operational_area_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
ORDER BY relation.valid_from DESC,relation.created_at DESC
|
||||
LIMIT 1
|
||||
) AS "operatorCompany",
|
||||
asset.information_status AS "informationStatus",
|
||||
asset.operational_status AS "operationalStatus",
|
||||
0::integer AS "childrenCount",
|
||||
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
|
||||
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
|
||||
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
|
||||
asset.data_origin AS "dataOrigin",
|
||||
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
|
||||
asset.current_version AS "currentVersion",
|
||||
asset.updated_at AS "updatedAt"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ST_GeometryType(geometry.geometry)::text AS type
|
||||
FROM asset_geometries geometry
|
||||
WHERE geometry.asset_id=asset.id
|
||||
ORDER BY geometry.updated_at DESC
|
||||
LIMIT 1
|
||||
) geometry_type ON true
|
||||
WHERE ${where}
|
||||
ORDER BY asset.name,asset.code
|
||||
LIMIT ${limit} OFFSET ${offsetParam}
|
||||
`,params);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta:{
|
||||
page:query.page,
|
||||
pageSize:query.pageSize,
|
||||
total,
|
||||
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async areas(query: InventoryBrowserQueryDto) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [
|
||||
"type.operational_role='AREA'",
|
||||
"area.information_status<>'INACTIVE'",
|
||||
'type.is_active=true',
|
||||
];
|
||||
const add = (value: unknown): string => {
|
||||
params.push(value);
|
||||
return `$${params.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const p = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
|
||||
}
|
||||
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
|
||||
if (query.operatorCompanyId) {
|
||||
const p = add(query.operatorCompanyId);
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM area_company_relations relation
|
||||
WHERE relation.area_id=area.id
|
||||
AND relation.company_id=${p}::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
)`);
|
||||
}
|
||||
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
area.id,area.code,area.name,area.common_name AS "commonName",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
area.information_status AS "informationStatus",
|
||||
area.operational_status AS "operationalStatus",
|
||||
(
|
||||
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=area.id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
ORDER BY relation.valid_from DESC,relation.created_at DESC
|
||||
LIMIT 1
|
||||
) AS "currentOperator",
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM assets yacimiento
|
||||
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
|
||||
WHERE yacimiento.parent_id=area.id
|
||||
AND lower(ytype.code)='yacimiento'
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
) AS "yacimientoCount",
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM assets inventory
|
||||
WHERE inventory.is_inventory_instance=true
|
||||
AND inventory.information_status<>'INACTIVE'
|
||||
AND inventory.operational_area_id=area.id
|
||||
) AS "inventoryCount"
|
||||
FROM assets area
|
||||
JOIN asset_types type ON type.id=area.asset_type_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY area.name,area.code
|
||||
`, params);
|
||||
|
||||
return { data, meta: { count: data.length } };
|
||||
}
|
||||
|
||||
async children(parentId: string, query: InventoryBrowserQueryDto) {
|
||||
const parent = await this.parent(parentId);
|
||||
const allowedChildType = this.allowedChildType(parent.typeCode);
|
||||
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
|
||||
|
||||
const params: unknown[] = [parentId, allowedChildType];
|
||||
const conditions = [
|
||||
'asset.parent_id=$1::uuid',
|
||||
'lower(type.code)=lower($2)',
|
||||
"asset.information_status<>'INACTIVE'",
|
||||
'type.is_active=true',
|
||||
];
|
||||
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
|
||||
conditions.push('asset.is_inventory_instance=true');
|
||||
}
|
||||
if (query.search?.trim()) {
|
||||
params.push(`%${query.search.trim()}%`);
|
||||
const p = `$${params.length}`;
|
||||
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
|
||||
}
|
||||
|
||||
params.push(201);
|
||||
const limit = `$${params.length}`;
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT
|
||||
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
|
||||
) END AS parent,
|
||||
asset.information_status AS "informationStatus",
|
||||
asset.operational_status AS "operationalStatus",
|
||||
asset.is_inventory_instance AS "isInventoryInstance",
|
||||
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',family.id,'code',family.code,'name',family.name,'level',family.level
|
||||
) END AS "inventoryFamily",
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM assets child
|
||||
WHERE child.parent_id=asset.id
|
||||
AND child.information_status<>'INACTIVE'
|
||||
AND (
|
||||
lower(type.code)='area'
|
||||
OR child.is_inventory_instance=true
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM asset_types child_type
|
||||
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
|
||||
)
|
||||
)
|
||||
) AS "childrenCount",
|
||||
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
|
||||
asset.updated_at AS "updatedAt"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY asset.name,asset.code
|
||||
LIMIT ${limit}
|
||||
`, params);
|
||||
|
||||
const hasMore = rows.length > 200;
|
||||
const data = hasMore ? rows.slice(0,200) : rows;
|
||||
return { parent, data, meta: { count: data.length, hasMore } };
|
||||
}
|
||||
|
||||
private async parent(parentId: string): Promise<ParentContext> {
|
||||
const [parent] = await this.dataSource.query(`
|
||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
|
||||
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 ParentContext[];
|
||||
if (!parent) {
|
||||
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
|
||||
}
|
||||
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
|
||||
throw new BadRequestException({
|
||||
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
|
||||
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private allowedChildType(typeCode: string): string | null {
|
||||
switch (typeCode.toLowerCase()) {
|
||||
case 'area': return 'yacimiento';
|
||||
case 'yacimiento': return 'instalacion';
|
||||
case 'instalacion': return 'subinstalacion';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,66 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
CreateInventoryFamilyDto,
|
||||
ReplaceInventoryFamilyFindingsDto,
|
||||
UpdateInventoryFamilyDto,
|
||||
} from './dto/inventory-family-admin.dto';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
|
||||
@Controller('inventory-families')
|
||||
export class InventoryFamilyCatalogController {
|
||||
constructor(private readonly families: InventoryFamilyCatalogService) {}
|
||||
|
||||
@Get('admin')
|
||||
@RequirePermissions('asset_types.read')
|
||||
admin() {
|
||||
return this.families.listAdmin();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('asset_types.manage')
|
||||
create(
|
||||
@Body() dto: CreateInventoryFamilyDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.families.create(dto,principal,request);
|
||||
}
|
||||
|
||||
@Patch(':familyId')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
update(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Body() dto:UpdateInventoryFamilyDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.update(familyId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Put(':familyId/findings')
|
||||
@RequirePermissions('finding_catalog.manage')
|
||||
replaceFindings(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Body() dto:ReplaceInventoryFamilyFindingsDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.replaceFindings(familyId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Get(':familyId/findings')
|
||||
@RequirePermissions('assets.read')
|
||||
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,22 @@ type MergeRow = {
|
||||
requestId: string | null;
|
||||
};
|
||||
|
||||
type DocumentInvariantRow = {
|
||||
actId: string;
|
||||
actStatus: string;
|
||||
lockedSha256: string | null;
|
||||
closureSha256: string | null;
|
||||
sealedAt: Date | null;
|
||||
actVersion: number;
|
||||
reportId: string | null;
|
||||
reportStatus: string | null;
|
||||
reportActClosureSha256: string | null;
|
||||
reportFrozenSha256: string | null;
|
||||
gedoPdfSha256: string | null;
|
||||
wordSha256: string | null;
|
||||
reportRevision: number | null;
|
||||
};
|
||||
|
||||
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
|
||||
|
||||
@Injectable()
|
||||
@@ -161,6 +177,19 @@ export class InventoryMergeService {
|
||||
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||
|
||||
this.validatePair(source, canonical);
|
||||
|
||||
// Empresa is temporal inspection context, not physical ownership. A merge is
|
||||
// valid when both records resolve to the same Area in the physical hierarchy,
|
||||
// even if their historical operator snapshots differ.
|
||||
const [sourceAreaId, canonicalAreaId] = await Promise.all([
|
||||
this.resolvePhysicalAreaId(manager, source.id),
|
||||
this.resolvePhysicalAreaId(manager, canonical.id),
|
||||
]);
|
||||
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
|
||||
code: 'INVENTORY_MERGE_AREA_MISMATCH',
|
||||
message: 'Los duplicados deben pertenecer a la misma Área física',
|
||||
});
|
||||
|
||||
const sourceParentCanonical = source.parentId
|
||||
? await this.resolveCanonicalId(manager, source.parentId)
|
||||
: null;
|
||||
@@ -197,6 +226,8 @@ export class InventoryMergeService {
|
||||
});
|
||||
}
|
||||
|
||||
const affectedAssetIds = [source.id, canonical.id];
|
||||
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
|
||||
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
|
||||
this.snapshot(manager, source.id),
|
||||
this.snapshot(manager, canonical.id),
|
||||
@@ -279,6 +310,17 @@ export class InventoryMergeService {
|
||||
request,
|
||||
);
|
||||
|
||||
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
|
||||
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
|
||||
// before committing the transaction; otherwise rollback the entire merge.
|
||||
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
|
||||
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
|
||||
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
merge: mergeRecord,
|
||||
source: { id: source.id, code: source.code, name: source.name },
|
||||
@@ -286,6 +328,7 @@ export class InventoryMergeService {
|
||||
sourceVersionNumber,
|
||||
reparentedChildIds,
|
||||
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
||||
documentaryInvariantsVerified: true,
|
||||
};
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -298,10 +341,12 @@ export class InventoryMergeService {
|
||||
operation: 'CHRONOLOGICAL_MERGE',
|
||||
sourceAssetId: source.id,
|
||||
canonicalAssetId: canonical.id,
|
||||
physicalAreaId: sourceAreaId,
|
||||
reason: dto.reason,
|
||||
sourceVersionNumber,
|
||||
reparentedChildIds,
|
||||
historicalReferencesRewritten: false,
|
||||
documentaryInvariantsVerified: true,
|
||||
},
|
||||
}, manager);
|
||||
return result;
|
||||
@@ -322,14 +367,59 @@ export class InventoryMergeService {
|
||||
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
||||
message: 'El registro canónico no puede estar inactivo',
|
||||
});
|
||||
if (!source.operationalAreaId || !source.operatorCompanyId
|
||||
|| source.operationalAreaId !== canonical.operationalAreaId
|
||||
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
|
||||
message: 'Los registros deben pertenecer a la misma Área y Operadora',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
|
||||
const rows = (await manager.query(`
|
||||
WITH RECURSIVE lineage AS (
|
||||
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
|
||||
FROM assets asset WHERE asset.id=$1::uuid
|
||||
UNION ALL
|
||||
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
|
||||
FROM assets parent
|
||||
JOIN lineage ON lineage.parent_id=parent.id
|
||||
WHERE lineage.depth<32
|
||||
)
|
||||
SELECT lineage.id
|
||||
FROM lineage
|
||||
JOIN asset_types type ON type.id=lineage.asset_type_id
|
||||
WHERE type.operational_role='AREA'
|
||||
ORDER BY lineage.depth
|
||||
LIMIT 1
|
||||
`, [assetId])) as Array<{ id: string }>;
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
|
||||
return (await manager.query(`
|
||||
WITH affected_acts AS (
|
||||
SELECT DISTINCT act.id
|
||||
FROM inspection_acts act
|
||||
LEFT JOIN inspection_act_assets act_asset
|
||||
ON act_asset.act_id=act.id AND act_asset.included=true
|
||||
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
|
||||
WHERE act_asset.asset_id=ANY($1::uuid[])
|
||||
OR finding.asset_id=ANY($1::uuid[])
|
||||
)
|
||||
SELECT
|
||||
act.id AS "actId",
|
||||
act.status AS "actStatus",
|
||||
act.locked_sha256 AS "lockedSha256",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
act.sealed_at AS "sealedAt",
|
||||
act.current_version AS "actVersion",
|
||||
report.id AS "reportId",
|
||||
report.status AS "reportStatus",
|
||||
report.act_closure_sha256 AS "reportActClosureSha256",
|
||||
report.frozen_sha256 AS "reportFrozenSha256",
|
||||
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||
report.word_sha256 AS "wordSha256",
|
||||
report.current_revision_number AS "reportRevision"
|
||||
FROM affected_acts affected
|
||||
JOIN inspection_acts act ON act.id=affected.id
|
||||
LEFT JOIN inspection_reports report ON report.act_id=act.id
|
||||
ORDER BY act.id,report.id NULLS FIRST
|
||||
`, [assetIds])) as DocumentInvariantRow[];
|
||||
}
|
||||
|
||||
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
|
||||
|
||||
@@ -36,18 +36,17 @@ type ParentRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
operationalAreaId: string | null;
|
||||
operatorCompanyId: string | null;
|
||||
inventoryFamilyId: string | null;
|
||||
};
|
||||
|
||||
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
|
||||
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
||||
AREA: 'area',
|
||||
YACIMIENTO: 'yacimiento',
|
||||
INSTALACION: 'instalacion',
|
||||
SUBINSTALACION: 'subinstalacion',
|
||||
};
|
||||
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||
EMPRESA: null,
|
||||
AREA: null,
|
||||
YACIMIENTO: 'area',
|
||||
INSTALACION: 'yacimiento',
|
||||
@@ -70,18 +69,29 @@ export class InventoryStructureService {
|
||||
const types = (await this.dataSource.query(`
|
||||
SELECT id,code,name
|
||||
FROM asset_types
|
||||
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||
AND is_active=true
|
||||
ORDER BY CASE lower(code)
|
||||
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
|
||||
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
|
||||
WHERE (
|
||||
lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||
OR operational_role='COMPANY'
|
||||
) AND is_active=true
|
||||
ORDER BY CASE
|
||||
WHEN operational_role='COMPANY' THEN 0
|
||||
WHEN lower(code)='area' THEN 1
|
||||
WHEN lower(code)='yacimiento' THEN 2
|
||||
WHEN lower(code)='instalacion' THEN 3
|
||||
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
|
||||
`)) as StructureTypeRow[];
|
||||
if (types.length !== 4) {
|
||||
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
|
||||
const area = types.find((item) => item.code.toLowerCase()==='area');
|
||||
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
|
||||
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
|
||||
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
|
||||
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
||||
message: 'La estructura del Inventario todavía no está completamente configurada',
|
||||
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
|
||||
});
|
||||
}
|
||||
|
||||
const families = (await this.dataSource.query(`
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.legacy_type_code AS "legacyTypeCode",
|
||||
@@ -93,12 +103,16 @@ export class InventoryStructureService {
|
||||
WHERE family.is_active=true
|
||||
ORDER BY family.level,family.name,family.code
|
||||
`)) as FamilyRow[];
|
||||
|
||||
return {
|
||||
independentMasters: [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
|
||||
],
|
||||
levels: [
|
||||
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
||||
{ kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
|
||||
],
|
||||
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||
@@ -107,7 +121,7 @@ export class InventoryStructureService {
|
||||
|
||||
async parents(kindValue: string, search?: string) {
|
||||
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
||||
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
|
||||
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
||||
message: 'El nivel indicado no requiere un registro padre',
|
||||
@@ -154,9 +168,8 @@ export class InventoryStructureService {
|
||||
const type = await this.requireStructureType(manager, dto.kind);
|
||||
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||
const operationalAreaId = parent?.operationalAreaId ?? null;
|
||||
const operatorCompanyId = parent?.operatorCompanyId ?? null;
|
||||
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||
const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
|
||||
|
||||
const inserted = (await manager.query(`
|
||||
INSERT INTO assets (
|
||||
@@ -164,30 +177,37 @@ export class InventoryStructureService {
|
||||
code,name,common_name,description,information_status,operational_status,
|
||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||
) VALUES (
|
||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
||||
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
|
||||
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
||||
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
|
||||
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
|
||||
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
|
||||
) RETURNING id
|
||||
`, [
|
||||
type.id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
family?.id ?? null,
|
||||
code,
|
||||
generatedCode,
|
||||
dto.name,
|
||||
dto.commonName ?? null,
|
||||
dto.description ?? null,
|
||||
AssetInformationStatus.DRAFT,
|
||||
AssetOperationalStatus.UNKNOWN,
|
||||
AssetDataOrigin.MANUAL,
|
||||
'Inventario estructural F3.1',
|
||||
`inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
|
||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
||||
principal.userId,
|
||||
])) as Array<{ id: string }>;
|
||||
const id = inserted[0]?.id;
|
||||
if (!id) throw new Error('No se pudo crear el registro estructural');
|
||||
if (!id) throw new Error('No se pudo crear el registro');
|
||||
|
||||
if (dto.kind === 'EMPRESA') {
|
||||
await manager.query(`
|
||||
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
|
||||
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
|
||||
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
|
||||
`,[id,dto.name,principal.userId]);
|
||||
}
|
||||
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
@@ -200,13 +220,12 @@ export class InventoryStructureService {
|
||||
INSERT INTO asset_context_history (
|
||||
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||
change_reason,asset_version_number,source,request_id,created_by
|
||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
|
||||
`, [
|
||||
id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
'Alta guiada de Inventario estructural F3.1',
|
||||
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
|
||||
versionNumber,
|
||||
request.requestId,
|
||||
principal.userId,
|
||||
@@ -223,6 +242,7 @@ export class InventoryStructureService {
|
||||
inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,
|
||||
inventoryFamilyCode: family?.code ?? null,
|
||||
operatorOwnership: false,
|
||||
},
|
||||
}, manager);
|
||||
return created;
|
||||
@@ -231,7 +251,7 @@ export class InventoryStructureService {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un registro de Inventario con ese código',
|
||||
message: 'Ya existe un registro con ese código',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
@@ -239,10 +259,11 @@ export class InventoryStructureService {
|
||||
}
|
||||
|
||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||
const rows = (await manager.query(`
|
||||
SELECT id,code,name FROM asset_types
|
||||
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
||||
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
|
||||
const sql = kind === 'EMPRESA'
|
||||
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
|
||||
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
|
||||
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
|
||||
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
|
||||
if (!rows[0]) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||
@@ -261,8 +282,10 @@ export class InventoryStructureService {
|
||||
if (!expectedType) {
|
||||
if (parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
||||
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
||||
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
||||
message: kind === 'EMPRESA'
|
||||
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
||||
: 'Un Área es un registro raíz y no puede tener padre',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -275,8 +298,6 @@ export class InventoryStructureService {
|
||||
}
|
||||
const rows = (await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||
asset.operational_area_id AS "operationalAreaId",
|
||||
asset.operator_company_id AS "operatorCompanyId",
|
||||
asset.inventory_family_id AS "inventoryFamilyId"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
@@ -294,6 +315,30 @@ export class InventoryStructureService {
|
||||
return parent;
|
||||
}
|
||||
|
||||
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
|
||||
if (parent.typeCode.toLowerCase()==='area') return parent.id;
|
||||
const rows = (await manager.query(`
|
||||
WITH RECURSIVE lineage AS (
|
||||
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
|
||||
UNION ALL
|
||||
SELECT parent.id,parent.parent_id,parent.asset_type_id
|
||||
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
|
||||
)
|
||||
SELECT lineage.id
|
||||
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
|
||||
WHERE type.operational_role='AREA'
|
||||
LIMIT 1
|
||||
`,[parent.id])) as IdRow[];
|
||||
const areaId=rows[0]?.id;
|
||||
if (!areaId) {
|
||||
throw new ConflictException({
|
||||
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
|
||||
message:'La ubicación seleccionada no pertenece a un Área válida',
|
||||
});
|
||||
}
|
||||
return areaId;
|
||||
}
|
||||
|
||||
private async requireFamily(
|
||||
manager: EntityManager,
|
||||
kind: InventoryStructureKind,
|
||||
@@ -304,7 +349,7 @@ export class InventoryStructureService {
|
||||
if (!expectedLevel) {
|
||||
if (familyId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||
message: 'Área y Yacimiento no llevan familia técnica',
|
||||
message: 'Empresa, Área y Yacimiento no llevan familia técnica',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -339,7 +384,7 @@ export class InventoryStructureService {
|
||||
}
|
||||
|
||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||
const readable = name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
@@ -356,6 +401,7 @@ export class InventoryStructureService {
|
||||
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||
'informationLabels',family.information_labels
|
||||
@@ -364,9 +410,12 @@ export class InventoryStructureService {
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid
|
||||
`, [id]);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
type IdRow = { id: string };
|
||||
|
||||
Reference in New Issue
Block a user