F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.27.0-1",
|
||||
"version": "0.28.0-1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -43,4 +43,4 @@
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { InventoryStructureController } from './inventory-structure.controller';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
@@ -40,6 +42,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryTechnicalValuesController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
@@ -57,6 +60,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryTechnicalValuesService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
|
||||
@@ -22,8 +22,10 @@ export class CreateInventoryFamilyDto {
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentFamilyId?: string | null;
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('4', { each: true })
|
||||
parentFamilyIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@@ -42,8 +44,10 @@ export class UpdateInventoryFamilyDto {
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentFamilyId?: string | null;
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('4', { each: true })
|
||||
parentFamilyIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
|
||||
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
|
||||
|
||||
export class CreateInventoryFamilyAttributeDto {
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||
@IsString()
|
||||
@Matches(/^[a-z][a-z0-9_]*$/)
|
||||
@MaxLength(80)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@IsIn(DATA_TYPES)
|
||||
dataType!: InventoryFamilyAttributeDataType;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@ValidateIf((_object, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string | null;
|
||||
|
||||
@ValidateIf((object) => object.dataType === 'SELECT')
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
options?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10000)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateInventoryFamilyAttributeDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DATA_TYPES)
|
||||
dataType?: InventoryFamilyAttributeDataType;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@ValidateIf((_object, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
options?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10000)
|
||||
sortOrder?: number;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsObject } from 'class-validator';
|
||||
|
||||
export class UpdateInventoryTechnicalValuesDto {
|
||||
@IsObject()
|
||||
values!: Record<string, unknown>;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
ReplaceInventoryFamilyFindingsDto,
|
||||
UpdateInventoryFamilyDto,
|
||||
} from './dto/inventory-family-admin.dto';
|
||||
import {
|
||||
CreateInventoryFamilyAttributeDto,
|
||||
UpdateInventoryFamilyAttributeDto,
|
||||
} from './dto/inventory-family-attribute.dto';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
|
||||
@Controller('inventory-families')
|
||||
@@ -50,6 +54,35 @@ export class InventoryFamilyCatalogController {
|
||||
return this.families.update(familyId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Get(':familyId/attributes')
|
||||
@RequirePermissions('asset_types.read')
|
||||
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
|
||||
return this.families.attributes(familyId);
|
||||
}
|
||||
|
||||
@Post(':familyId/attributes')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
createAttribute(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Body() dto:CreateInventoryFamilyAttributeDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.createAttribute(familyId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Patch(':familyId/attributes/:attributeId')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
updateAttribute(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
|
||||
@Body() dto:UpdateInventoryFamilyAttributeDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.updateAttribute(familyId,attributeId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Put(':familyId/findings')
|
||||
@RequirePermissions('finding_catalog.manage')
|
||||
replaceFindings(
|
||||
|
||||
@@ -15,7 +15,13 @@ import type {
|
||||
ReplaceInventoryFamilyFindingsDto,
|
||||
UpdateInventoryFamilyDto,
|
||||
} from './dto/inventory-family-admin.dto';
|
||||
import type {
|
||||
CreateInventoryFamilyAttributeDto,
|
||||
InventoryFamilyAttributeDataType,
|
||||
UpdateInventoryFamilyAttributeDto,
|
||||
} from './dto/inventory-family-attribute.dto';
|
||||
|
||||
type FamilyParent = { id: string; code: string; name: string };
|
||||
type FamilyRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -24,12 +30,24 @@ type FamilyRow = {
|
||||
informationLabels: string[];
|
||||
sourceReference: string | null;
|
||||
isActive: boolean;
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
parentFamilyIds: string[];
|
||||
parentFamilies: FamilyParent[];
|
||||
assetCount: number;
|
||||
findingCount: number;
|
||||
findingItemIds: string[];
|
||||
technicalAttributeCount: number;
|
||||
};
|
||||
type AttributeRow = {
|
||||
id: string;
|
||||
inventoryFamilyId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dataType: InventoryFamilyAttributeDataType;
|
||||
isRequired: boolean;
|
||||
isActive: boolean;
|
||||
unit: string | null;
|
||||
options: string[] | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -40,27 +58,7 @@ export class InventoryFamilyCatalogService {
|
||||
) {}
|
||||
|
||||
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[];
|
||||
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
@@ -80,27 +78,28 @@ export class InventoryFamilyCatalogService {
|
||||
return { family, items, count: items.length };
|
||||
}
|
||||
|
||||
async attributes(familyId: string) {
|
||||
const family = await this.family(familyId, false);
|
||||
const items = await this.attributeRows(this.dataSource.manager, 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 parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], 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)
|
||||
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',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]);
|
||||
}
|
||||
await this.replaceParents(manager, inserted.id, parentIds);
|
||||
const created = await this.family(inserted.id,false,manager);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
@@ -108,7 +107,7 @@ export class InventoryFamilyCatalogService {
|
||||
entityType: 'inventory_family',
|
||||
entityId: inserted.id,
|
||||
afterData: created as unknown as Record<string,unknown>,
|
||||
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
|
||||
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
|
||||
},manager);
|
||||
return created;
|
||||
});
|
||||
@@ -122,10 +121,13 @@ export class InventoryFamilyCatalogService {
|
||||
) {
|
||||
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);
|
||||
const nextParentIds = dto.parentFamilyIds === undefined
|
||||
? before.parentFamilyIds
|
||||
: dto.parentFamilyIds;
|
||||
const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId);
|
||||
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
|
||||
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
|
||||
}
|
||||
await manager.query(`
|
||||
UPDATE inventory_families SET
|
||||
name=COALESCE($2::varchar,name),
|
||||
@@ -139,15 +141,7 @@ export class InventoryFamilyCatalogService {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
|
||||
const after = await this.family(familyId,false,manager);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
@@ -162,6 +156,85 @@ export class InventoryFamilyCatalogService {
|
||||
});
|
||||
}
|
||||
|
||||
async createAttribute(
|
||||
familyId: string,
|
||||
dto: CreateInventoryFamilyAttributeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.family(familyId,false,manager,true);
|
||||
const options = this.attributeOptions(dto.dataType,dto.options);
|
||||
try {
|
||||
const [created] = (await manager.query(`
|
||||
INSERT INTO inventory_family_attribute_definitions(
|
||||
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
|
||||
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
|
||||
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
|
||||
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
|
||||
if (!created) throw new Error('No se pudo crear el campo técnico');
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
action: AuditAction.ASSET_UPDATED,
|
||||
entityType:'inventory_family_attribute',entityId:created.id,
|
||||
afterData:created as unknown as Record<string,unknown>,
|
||||
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
|
||||
},manager);
|
||||
return created;
|
||||
} catch (error) {
|
||||
if (this.isUniqueViolation(error)) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
|
||||
message:'Ya existe un campo técnico con ese código en la clasificación',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateAttribute(
|
||||
familyId: string,
|
||||
attributeId: string,
|
||||
dto: UpdateInventoryFamilyAttributeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.family(familyId,false,manager,true);
|
||||
const before = await this.attribute(manager,familyId,attributeId,true);
|
||||
const nextType = dto.dataType ?? before.dataType;
|
||||
const nextOptions = dto.options === undefined
|
||||
? this.attributeOptions(nextType,before.options ?? undefined)
|
||||
: this.attributeOptions(nextType,dto.options ?? undefined);
|
||||
const [after] = (await manager.query(`
|
||||
UPDATE inventory_family_attribute_definitions SET
|
||||
name=COALESCE($3::varchar,name),
|
||||
data_type=COALESCE($4::asset_attribute_data_type,data_type),
|
||||
is_required=COALESCE($5::boolean,is_required),
|
||||
is_active=COALESCE($6::boolean,is_active),
|
||||
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
|
||||
options=$9::jsonb,
|
||||
sort_order=COALESCE($10::integer,sort_order),
|
||||
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
|
||||
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
|
||||
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
|
||||
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
|
||||
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
action: AuditAction.ASSET_UPDATED,
|
||||
entityType:'inventory_family_attribute',entityId:attributeId,
|
||||
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
|
||||
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
|
||||
},manager);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
async replaceFindings(
|
||||
familyId: string,
|
||||
dto: ReplaceInventoryFamilyFindingsDto,
|
||||
@@ -178,12 +251,10 @@ export class InventoryFamilyCatalogService {
|
||||
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',
|
||||
});
|
||||
}
|
||||
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]);
|
||||
@@ -198,11 +269,9 @@ export class InventoryFamilyCatalogService {
|
||||
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 },
|
||||
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);
|
||||
});
|
||||
@@ -217,77 +286,150 @@ export class InventoryFamilyCatalogService {
|
||||
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
|
||||
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 familySelect(suffix:string) {
|
||||
return `
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.information_labels AS "informationLabels",
|
||||
family.source_reference AS "sourceReference",family.is_active AS "isActive",
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
|
||||
(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",
|
||||
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
|
||||
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
|
||||
FROM inventory_families family
|
||||
${suffix}
|
||||
`;
|
||||
}
|
||||
|
||||
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
|
||||
const lockClause=lock ? 'FOR UPDATE OF family' : '';
|
||||
const rows=(await manager.query(this.familySelect(`
|
||||
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
|
||||
${lock ? 'FOR UPDATE OF family' : ''}
|
||||
`,[familyId])) as FamilyRow[];
|
||||
${lockClause}
|
||||
`),[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(
|
||||
private async validateParents(
|
||||
manager:EntityManager,
|
||||
level:'INSTALLATION'|'SUBINSTALLATION',
|
||||
parentFamilyId:string|null,
|
||||
requestedIds:string[],
|
||||
ownId:string|null,
|
||||
):Promise<string|null> {
|
||||
):Promise<string[]> {
|
||||
const ids=[...new Set(requestedIds)];
|
||||
if (level==='INSTALLATION') {
|
||||
if (parentFamilyId) throw new BadRequestException({
|
||||
if (ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
||||
message:'Una clasificación de Instalación no tiene clasificación padre',
|
||||
message:'Una clasificación de Instalación no lleva compatibilidades padre',
|
||||
});
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
if (!parentFamilyId) throw new BadRequestException({
|
||||
if (!ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
|
||||
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
|
||||
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
|
||||
});
|
||||
if (parentFamilyId===ownId) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
|
||||
if (ownId && ids.includes(ownId)) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
|
||||
});
|
||||
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({
|
||||
const [count]=(await manager.query(`
|
||||
SELECT COUNT(*)::integer AS total FROM inventory_families
|
||||
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
|
||||
`,[ids])) as Array<{total:number}>;
|
||||
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_INVALID',
|
||||
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
|
||||
message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
|
||||
});
|
||||
return parentFamilyId;
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
|
||||
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
|
||||
if (parentIds.length) await manager.query(`
|
||||
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
|
||||
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
|
||||
`,[familyId,parentIds]);
|
||||
}
|
||||
|
||||
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
|
||||
const rows=await manager.query(`
|
||||
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
|
||||
FROM assets child
|
||||
JOIN assets parent ON parent.id=child.parent_id
|
||||
WHERE child.inventory_family_id=$1::uuid
|
||||
AND parent.inventory_family_id IS NOT NULL
|
||||
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
|
||||
GROUP BY child.name,parent.name
|
||||
ORDER BY total DESC
|
||||
LIMIT 1
|
||||
`,[childFamilyId,nextParentIds]);
|
||||
if (rows[0]) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
|
||||
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
|
||||
return manager.query(`
|
||||
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE inventory_family_id=$1::uuid
|
||||
ORDER BY is_active DESC,sort_order,name,code
|
||||
`,[familyId]) as Promise<AttributeRow[]>;
|
||||
}
|
||||
|
||||
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
|
||||
const rows=(await manager.query(`
|
||||
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
|
||||
${lock ? 'FOR UPDATE' : ''}
|
||||
`,[attributeId,familyId])) as AttributeRow[];
|
||||
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
|
||||
if (type!=='SELECT') return null;
|
||||
const values=[...new Map((raw ?? []).map((value) => {
|
||||
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
|
||||
}).filter(([,value]) => Boolean(value))).values()];
|
||||
if (!values.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
|
||||
message:'Un campo de lista necesita al menos una opción',
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
private cleanLabels(labels:string[]):string[] {
|
||||
const unique=new Map<string,string>();
|
||||
for (const raw of labels) {
|
||||
const clean=raw.trim();
|
||||
if (!clean) continue;
|
||||
const clean=raw.trim(); if (!clean) continue;
|
||||
const identity=clean.toLocaleLowerCase('es-AR');
|
||||
if (!unique.has(identity)) unique.set(identity,clean);
|
||||
}
|
||||
@@ -296,4 +438,8 @@ export class InventoryFamilyCatalogService {
|
||||
});
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
private isUniqueViolation(error:unknown) {
|
||||
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
|
||||
type StructureTypeRow = { id: string; code: string; name: string };
|
||||
type FamilyParent = { id: string; code: string; name: string };
|
||||
type FamilyRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -27,9 +28,8 @@ type FamilyRow = {
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
legacyTypeCode: string | null;
|
||||
informationLabels: string[];
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
parentFamilyIds: string[];
|
||||
parentFamilies: FamilyParent[];
|
||||
};
|
||||
type ParentRow = {
|
||||
id: string;
|
||||
@@ -101,10 +101,15 @@ export class InventoryStructureService {
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.legacy_type_code AS "legacyTypeCode",
|
||||
family.information_labels AS "informationLabels",
|
||||
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
||||
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.is_active=true
|
||||
ORDER BY family.level,family.name,family.code
|
||||
`)) as FamilyRow[];
|
||||
@@ -201,7 +206,7 @@ export class InventoryStructureService {
|
||||
AssetInformationStatus.DRAFT,
|
||||
AssetOperationalStatus.UNKNOWN,
|
||||
AssetDataOrigin.MANUAL,
|
||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F5.1' : 'Estructura manual de Inventario F5.1',
|
||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
|
||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
|
||||
principal.userId,
|
||||
@@ -218,11 +223,7 @@ export class InventoryStructureService {
|
||||
}
|
||||
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
id,
|
||||
AssetVersionChangeType.CREATED,
|
||||
principal,
|
||||
request,
|
||||
manager,id,AssetVersionChangeType.CREATED,principal,request,
|
||||
);
|
||||
await manager.query(`
|
||||
INSERT INTO asset_context_history (
|
||||
@@ -230,38 +231,28 @@ export class InventoryStructureService {
|
||||
change_reason,asset_version_number,source,request_id,created_by
|
||||
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
|
||||
`, [
|
||||
id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F5.1' : 'Alta manual de estructura de Inventario F5.1',
|
||||
versionNumber,
|
||||
request.requestId,
|
||||
principal.userId,
|
||||
id,parent?.id ?? null,operationalAreaId,
|
||||
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
|
||||
versionNumber,request.requestId,principal.userId,
|
||||
]);
|
||||
const created = await this.loadView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_CREATED,
|
||||
entityType: 'asset',
|
||||
entityId: id,
|
||||
entityType: 'asset',entityId: id,
|
||||
afterData: created as unknown as Record<string, unknown>,
|
||||
metadata: {
|
||||
versionNumber,
|
||||
inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,
|
||||
inventoryFamilyCode: family?.code ?? null,
|
||||
versionNumber,inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
|
||||
operatorOwnership: false,
|
||||
},
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un registro con ese código',
|
||||
});
|
||||
}
|
||||
if (isUniqueViolation(error)) throw new ConflictException({
|
||||
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -272,12 +263,9 @@ export class InventoryStructureService {
|
||||
: `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',
|
||||
message: `El nivel ${kind} no está configurado`,
|
||||
});
|
||||
}
|
||||
if (!rows[0]) throw new ConflictException({
|
||||
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
|
||||
});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
@@ -288,22 +276,18 @@ export class InventoryStructureService {
|
||||
): Promise<ParentRow | null> {
|
||||
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||
if (!expectedType) {
|
||||
if (parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
||||
message: kind === 'EMPRESA'
|
||||
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
||||
: 'Un Departamento es un registro raíz y no puede tener padre',
|
||||
});
|
||||
}
|
||||
if (parentId) throw new BadRequestException({
|
||||
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
||||
message: kind === 'EMPRESA'
|
||||
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
||||
: 'Un Departamento es un registro raíz y no puede tener padre',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||
});
|
||||
}
|
||||
if (!parentId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||
});
|
||||
const rows = (await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||
asset.inventory_family_id AS "inventoryFamilyId"
|
||||
@@ -314,12 +298,10 @@ export class InventoryStructureService {
|
||||
`, [parentId])) as ParentRow[];
|
||||
const parent = rows[0];
|
||||
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
|
||||
if (parent.typeCode.toLowerCase() !== expectedType) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
}
|
||||
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
@@ -338,12 +320,10 @@ export class InventoryStructureService {
|
||||
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',
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -367,12 +347,13 @@ export class InventoryStructureService {
|
||||
});
|
||||
const rows = (await manager.query(`
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.legacy_type_code AS "legacyTypeCode",
|
||||
family.information_labels AS "informationLabels",
|
||||
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
|
||||
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
||||
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 AND family.is_active=true
|
||||
LIMIT 1
|
||||
`, [familyId])) as FamilyRow[];
|
||||
@@ -382,10 +363,19 @@ export class InventoryStructureService {
|
||||
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
||||
});
|
||||
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
|
||||
throw new BadRequestException({
|
||||
if (kind === 'SUBINSTALACION') {
|
||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
||||
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||
message:'La Instalación padre debe tener una clasificación técnica válida',
|
||||
});
|
||||
const [compatible]=(await manager.query(`
|
||||
SELECT 1 AS ok FROM inventory_family_parent_rules
|
||||
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
|
||||
LIMIT 1
|
||||
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
||||
if (!compatible) throw new BadRequestException({
|
||||
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||
message: 'La Subinstalación elegida no pertenece a la clasificación de la Instalación seleccionada',
|
||||
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
|
||||
});
|
||||
}
|
||||
return family;
|
||||
@@ -398,13 +388,8 @@ export class InventoryStructureService {
|
||||
: kind === 'YACIMIENTO' ? 'YAC'
|
||||
: kind === 'INSTALACION' ? 'INST'
|
||||
: 'SUB';
|
||||
const readable = name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'REGISTRO';
|
||||
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
|
||||
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
|
||||
@Controller('assets/:assetId/technical-values')
|
||||
export class InventoryTechnicalValuesController {
|
||||
constructor(private readonly technical:InventoryTechnicalValuesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
|
||||
return this.technical.get(assetId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermissions('assets.update')
|
||||
replace(
|
||||
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
|
||||
@Body() dto:UpdateInventoryTechnicalValuesDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.technical.replace(assetId,dto,principal,request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { BadRequestException, 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 { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
|
||||
|
||||
type DefinitionRow = {
|
||||
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
|
||||
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
|
||||
};
|
||||
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
|
||||
|
||||
@Injectable()
|
||||
export class InventoryTechnicalValuesService {
|
||||
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
|
||||
|
||||
async get(assetId:string) {
|
||||
return this.load(this.dataSource.manager,assetId);
|
||||
}
|
||||
|
||||
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
|
||||
return this.dataSource.transaction(async(manager) => {
|
||||
const before=await this.load(manager,assetId,true);
|
||||
const definitions=before.definitions as DefinitionRow[];
|
||||
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
|
||||
const normalized:Record<string,unknown>={};
|
||||
for (const [definitionId,raw] of Object.entries(dto.values)) {
|
||||
const definition=definitionById.get(definitionId);
|
||||
if (!definition || !definition.isActive) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
|
||||
});
|
||||
const value=this.normalize(definition,raw);
|
||||
if (value!==undefined) normalized[definitionId]=value;
|
||||
}
|
||||
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
|
||||
if (!(definition.id in normalized)) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
|
||||
});
|
||||
}
|
||||
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
|
||||
const entries=Object.entries(normalized);
|
||||
if (entries.length) await manager.query(`
|
||||
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
|
||||
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
|
||||
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
|
||||
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
|
||||
const after=await this.load(manager,assetId);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
|
||||
entityType:'asset_inventory_technical_values',entityId:assetId,
|
||||
beforeData:{values:before.values},afterData:{values:after.values},
|
||||
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
|
||||
},manager);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
private async load(manager:EntityManager,assetId:string,lock=false) {
|
||||
const rows=(await manager.query(`
|
||||
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
|
||||
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
|
||||
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
|
||||
`,[assetId])) as AssetRow[];
|
||||
const asset=rows[0];
|
||||
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
|
||||
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
|
||||
});
|
||||
const definitions=(await manager.query(`
|
||||
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
|
||||
unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE inventory_family_id=$1::uuid
|
||||
ORDER BY is_active DESC,sort_order,name,code
|
||||
`,[asset.inventoryFamilyId])) as DefinitionRow[];
|
||||
const valueRows=await manager.query(`
|
||||
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
|
||||
`,[assetId]) as Array<{id:string;value:unknown}>;
|
||||
return {
|
||||
assetId,
|
||||
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
|
||||
definitions,
|
||||
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
|
||||
};
|
||||
}
|
||||
|
||||
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
|
||||
if (raw===null || raw===undefined || raw==='') return undefined;
|
||||
switch(definition.dataType) {
|
||||
case 'TEXT': {
|
||||
if (typeof raw!=='string') return this.invalid(definition);
|
||||
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
|
||||
}
|
||||
case 'NUMBER': {
|
||||
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
|
||||
if (!Number.isFinite(value)) return this.invalid(definition); return value;
|
||||
}
|
||||
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
|
||||
case 'DATE': {
|
||||
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
|
||||
return raw;
|
||||
}
|
||||
case 'DATETIME': {
|
||||
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
|
||||
}
|
||||
case 'SELECT': {
|
||||
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private invalid(definition:DefinitionRow):never {
|
||||
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F6SolidInventoryModel1790091000000 implements MigrationInterface {
|
||||
name = 'F6SolidInventoryModel1790091000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// A technical Subinstallation family may be valid under several Installation
|
||||
// families. F3.1 used child_family_id as the PK, which made this relation
|
||||
// accidentally one-to-one from the child's point of view.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE constraint_name text;
|
||||
BEGIN
|
||||
SELECT con.conname INTO constraint_name
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid=con.conrelid
|
||||
WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p'
|
||||
LIMIT 1;
|
||||
IF constraint_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inventory_family_parent_rules
|
||||
ADD CONSTRAINT pk_inventory_family_parent_rules
|
||||
PRIMARY KEY (child_family_id,parent_family_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent
|
||||
ON inventory_family_parent_rules(parent_family_id,child_family_id)
|
||||
`);
|
||||
|
||||
// Protect compatibility semantics at database level.
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE child_level text; parent_level text;
|
||||
BEGIN
|
||||
SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id;
|
||||
SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id;
|
||||
IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_inventory_family_compatibility_rule
|
||||
BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule()
|
||||
`);
|
||||
|
||||
// Family-specific technical fields. Generic asset type attributes remain for
|
||||
// data genuinely shared by a whole structural level.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE,
|
||||
code varchar(80) NOT NULL,
|
||||
name varchar(160) NOT NULL,
|
||||
data_type asset_attribute_data_type NOT NULL,
|
||||
is_required boolean NOT NULL DEFAULT false,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
unit varchar(40) NULL,
|
||||
options jsonb NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_by uuid NULL,
|
||||
updated_by uuid NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code),
|
||||
CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
|
||||
CONSTRAINT chk_inventory_family_attribute_options CHECK (
|
||||
(data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array')
|
||||
OR (data_type<>'SELECT')
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family
|
||||
ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values (
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE,
|
||||
value jsonb NOT NULL,
|
||||
updated_by uuid NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(asset_id,definition_id)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition
|
||||
ON asset_inventory_attribute_values(definition_id,asset_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE asset_family uuid; definition_family uuid;
|
||||
BEGIN
|
||||
SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id;
|
||||
SELECT inventory_family_id INTO definition_family
|
||||
FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true;
|
||||
IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_asset_inventory_attribute_family
|
||||
BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family()
|
||||
`);
|
||||
|
||||
// Enforce classification level and Subinstallation compatibility on every
|
||||
// physical Inventory write, not just through the current WEB/API.
|
||||
// A FIELD_SURVEY+DRAFT row may exist momentarily without family because the
|
||||
// field-discovery transaction creates the provisional record before the F6
|
||||
// structure service assigns the family selected by the inspector. Such a row
|
||||
// remains unusable for findings until classification is present.
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE child_type text; family_level text; parent_family uuid;
|
||||
BEGIN
|
||||
SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
|
||||
IF child_type IN ('departamento','area','yacimiento') THEN
|
||||
IF NEW.inventory_family_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF child_type='instalacion' THEN
|
||||
IF NEW.inventory_family_id IS NULL THEN
|
||||
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica';
|
||||
END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Instalación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF child_type='subinstalacion' THEN
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre';
|
||||
END IF;
|
||||
IF NEW.inventory_family_id IS NULL THEN
|
||||
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere clasificación técnica';
|
||||
END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id;
|
||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación';
|
||||
END IF;
|
||||
IF parent_family IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM inventory_family_parent_rules rule
|
||||
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
|
||||
) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación de Subinstalación no es compatible con la Instalación padre';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_f6_asset_family_contract
|
||||
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract()
|
||||
`);
|
||||
|
||||
const [check] = (await queryRunner.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid
|
||||
WHERE r.relname='inventory_family_parent_rules' AND c.contype='p'
|
||||
AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk,
|
||||
to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes,
|
||||
to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values
|
||||
`)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>;
|
||||
if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) {
|
||||
throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()');
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions');
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()');
|
||||
await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent');
|
||||
|
||||
// Old schema allowed only one Installation family per Subinstallation family.
|
||||
// Keep a deterministic first relation if F6 data must be rolled back.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM inventory_family_parent_rules rule
|
||||
USING inventory_family_parent_rules keep
|
||||
WHERE rule.child_family_id=keep.child_family_id
|
||||
AND rule.parent_family_id>keep.parent_family_id
|
||||
`);
|
||||
await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inventory_family_parent_rules
|
||||
ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -36,19 +36,17 @@ export class F3FindingCatalogResolverService {
|
||||
enabled: true,
|
||||
code: 'OTHER',
|
||||
label: 'OTROS',
|
||||
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
|
||||
help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.',
|
||||
};
|
||||
|
||||
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
|
||||
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
|
||||
if (!asset.familyId || asset.familyActive !== true) {
|
||||
return {
|
||||
asset: { id: asset.id, code: asset.code, name: asset.name },
|
||||
inventoryFamily: null,
|
||||
typeConfigured: false,
|
||||
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
|
||||
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
|
||||
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
|
||||
? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.'
|
||||
: 'El elemento todavía no tiene una clasificación técnica activa.',
|
||||
categories: [],
|
||||
items: [],
|
||||
other,
|
||||
@@ -59,7 +57,6 @@ export class F3FindingCatalogResolverService {
|
||||
'mapping.inventory_family_id=$1::uuid',
|
||||
'item.is_active=true',
|
||||
'category.is_active=true',
|
||||
"lower(category.code)='f5model'",
|
||||
'merge_record.source_item_id IS NULL',
|
||||
];
|
||||
const params: unknown[] = [asset.familyId];
|
||||
@@ -107,7 +104,7 @@ export class F3FindingCatalogResolverService {
|
||||
level: asset.familyLevel,
|
||||
},
|
||||
typeConfigured: true,
|
||||
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
|
||||
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||
categories,
|
||||
items,
|
||||
other,
|
||||
|
||||
@@ -138,8 +138,31 @@ export class FieldFindingsService {
|
||||
asset.id AS "assetId",
|
||||
asset_type.code AS "assetTypeCode",
|
||||
asset.inventory_family_id AS "inventoryFamilyId",
|
||||
asset.operational_area_id AS "assetAreaId",
|
||||
asset.operator_company_id AS "assetCompanyId",
|
||||
(
|
||||
asset.operational_area_id=visit.operational_area_id
|
||||
OR asset.id=visit.operational_area_id
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
||||
UNION ALL
|
||||
SELECT parent.id,parent.parent_id
|
||||
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
||||
)
|
||||
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
|
||||
)
|
||||
) AS "insideArea",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id=visit.operational_area_id
|
||||
AND relation.company_id=visit.operator_company_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
AND (
|
||||
relation.valid_until IS NULL
|
||||
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
)
|
||||
) AS "operatorRelationValid",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_visit_members member
|
||||
@@ -199,8 +222,8 @@ export class FieldFindingsService {
|
||||
assetId: string | null;
|
||||
assetTypeCode: string | null;
|
||||
inventoryFamilyId: string | null;
|
||||
assetAreaId: string | null;
|
||||
assetCompanyId: string | null;
|
||||
insideArea: boolean;
|
||||
operatorRelationValid: boolean;
|
||||
assigned: boolean;
|
||||
selected: boolean;
|
||||
captureRequired: boolean;
|
||||
@@ -230,6 +253,12 @@ export class FieldFindingsService {
|
||||
message: 'La inspección no tiene Área y Operadora definidas',
|
||||
});
|
||||
}
|
||||
if (!row.operatorRelationValid) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
|
||||
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!row.assetId) {
|
||||
throw new NotFoundException({
|
||||
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
|
||||
@@ -245,13 +274,13 @@ export class FieldFindingsService {
|
||||
if (!row.inventoryFamilyId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
||||
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
|
||||
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
||||
});
|
||||
}
|
||||
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
|
||||
if (!row.insideArea) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
||||
message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
|
||||
message: 'El Inventario no pertenece al Área de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!row.selected) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.26.0-1';
|
||||
export const API_PHASE = 'F5';
|
||||
export const API_VERSION = '0.28.0-1';
|
||||
export const API_PHASE = 'F6';
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F5 release', () => {
|
||||
assert.equal(API_PHASE, 'F5');
|
||||
assert.equal(API_VERSION, '0.26.0-1');
|
||||
});
|
||||
test('health metadata reports the current F6 release', () => {
|
||||
assert.equal(API_PHASE, 'F6');
|
||||
assert.equal(API_VERSION, '0.28.0-1');
|
||||
});
|
||||
@@ -64,14 +64,14 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
|
||||
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
|
||||
});
|
||||
|
||||
test('F5.1 Configuration manages findings inline and filters Subinstallations by Installation', () => {
|
||||
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
|
||||
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
|
||||
|
||||
assert.match(configPage, /replaceInventoryFamilyFindings/);
|
||||
assert.match(configPage, /Agregar o quitar/);
|
||||
assert.match(configPage, /familyFindingIds/);
|
||||
assert.match(configPage, /selectedInstallationFamilyId/);
|
||||
assert.match(configPage, /family\.parentFamilyId === selectedInstallationFamilyId/);
|
||||
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
|
||||
assert.match(configPage, /Filtrar por tipo de Instalación/);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||
}
|
||||
|
||||
test('F5 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
test('F6 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 20/);
|
||||
assert.match(gradle, /versionName = "0\.13\.0"/);
|
||||
assert.match(gradle, /versionCode = 21/);
|
||||
assert.match(gradle, /versionName = "0\.14\.0"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
test('F5 field inventory exposes Other families as reviewable choices to Android', () => {
|
||||
test('F5/F6 field inventory exposes Other families as reviewable choices to Android', () => {
|
||||
const service = readFileSync(
|
||||
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
|
||||
'utf8',
|
||||
@@ -25,4 +25,4 @@ test('F5 field inventory exposes Other families as reviewable choices to Android
|
||||
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
||||
assert.match(service, /AS "isOther"/);
|
||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function source(path: string) {
|
||||
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
}
|
||||
|
||||
test('F6 models concrete Installation 1→N Subinstallations while each child keeps exactly one physical parent', () => {
|
||||
const asset = source('src/database/entities/asset.entity.ts');
|
||||
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
|
||||
|
||||
assert.match(asset, /@Column\(\{ name: 'parent_id', type: 'uuid', nullable: true \}\)/);
|
||||
assert.match(asset, /parentId!: string \| null/);
|
||||
assert.match(migration, /IF child_type='subinstalacion' THEN/);
|
||||
assert.match(migration, /IF NEW\.parent_id IS NULL THEN/);
|
||||
assert.doesNotMatch(asset, /parentIds!/);
|
||||
});
|
||||
|
||||
test('F6 makes technical Installation↔Subinstallation compatibility truly many-to-many', () => {
|
||||
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
|
||||
const catalog = source('src/asset-master/inventory-family-catalog.service.ts');
|
||||
|
||||
assert.match(migration, /PRIMARY KEY \(child_family_id,parent_family_id\)/);
|
||||
assert.match(catalog, /parentFamilyIds: string\[\]/);
|
||||
assert.match(catalog, /requestedIds:string\[\]/);
|
||||
assert.match(catalog, /INSERT INTO inventory_family_parent_rules\(child_family_id,parent_family_id\)/);
|
||||
assert.match(catalog, /UNNEST\(\$2::uuid\[\]\)/);
|
||||
assert.match(catalog, /ON CONFLICT \(child_family_id,parent_family_id\) DO NOTHING/);
|
||||
});
|
||||
|
||||
test('F6 database rejects a Subinstallation classification incompatible with its concrete Installation parent', () => {
|
||||
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
|
||||
|
||||
assert.match(migration, /SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW\.parent_id/);
|
||||
assert.match(migration, /rule\.child_family_id=NEW\.inventory_family_id AND rule\.parent_family_id=parent_family/);
|
||||
assert.match(migration, /La clasificación de Subinstalación no es compatible con la Instalación padre/);
|
||||
});
|
||||
|
||||
test('F6 technical values are defined by classification and cannot leak between families', () => {
|
||||
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
|
||||
const service = source('src/asset-master/inventory-technical-values.service.ts');
|
||||
|
||||
assert.match(migration, /CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions/);
|
||||
assert.match(migration, /CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values/);
|
||||
assert.match(migration, /El campo técnico no pertenece a la clasificación del Inventario/);
|
||||
assert.match(service, /inventory_family_attribute_definitions/);
|
||||
assert.match(service, /asset_inventory_attribute_values/);
|
||||
});
|
||||
|
||||
test('F6 field findings resolve by exact technical classification and keep OTROS available', () => {
|
||||
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
|
||||
|
||||
assert.match(resolver, /mapping\.inventory_family_id=\$1::uuid/);
|
||||
assert.match(resolver, /asset\.inventory_family_id AS "familyId"/);
|
||||
assert.doesNotMatch(resolver, /category\.code='f5model'/);
|
||||
assert.match(resolver, /code: 'OTHER'/);
|
||||
assert.match(resolver, /label: 'OTROS'/);
|
||||
});
|
||||
|
||||
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
|
||||
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
|
||||
|
||||
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
|
||||
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
|
||||
assert.match(fieldFindings, /FROM area_company_relations relation/);
|
||||
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
|
||||
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
|
||||
});
|
||||
|
||||
test('F6 field-created technical Inventory may be provisional only until the selected family is assigned', () => {
|
||||
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
|
||||
const fieldStructure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
|
||||
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
|
||||
|
||||
assert.match(migration, /NEW\.data_origin='FIELD_SURVEY' AND NEW\.information_status='DRAFT'/);
|
||||
assert.match(fieldStructure, /SET inventory_family_id=\$2::uuid/);
|
||||
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
|
||||
});
|
||||
|
||||
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
|
||||
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
|
||||
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
|
||||
|
||||
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
|
||||
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
|
||||
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
|
||||
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user