diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 140fb64..3f6379b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android APK -# F5: genera una APK debug verificable contra la API productiva F5. +# F6: genera una APK debug verificable contra la API productiva F6. on: push: @@ -62,7 +62,7 @@ jobs: - name: Upload APK uses: actions/upload-artifact@v4 with: - name: DH-Inspeccion-F5-0.13.0-debug + name: DH-Inspeccion-F6-0.14.0-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error - retention-days: 30 + retention-days: 30 \ No newline at end of file diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index d7ddaa0..c74bd54 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 20 - versionName = "0.13.0" + versionCode = 21 + versionName = "0.14.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -77,4 +77,4 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") -} +} \ No newline at end of file diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 96a0be7..788f56a 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { var inventory: List by mutableStateOf(emptyList()) private set + var inventoryParentId: String? by mutableStateOf(null) + private set var fieldTypes: List by mutableStateOf(emptyList()) private set var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) @@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { visits = emptyList() visit = null inventory = emptyList() + inventoryParentId = null fieldTypes = emptyList() selectedFieldAsset = null clearActState() @@ -112,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun openVisit(id: String) = launchBusy { visit = repository.visit(id) inventory = emptyList() + inventoryParentId = visit?.operationalArea?.id fieldTypes = emptyList() selectedFieldAsset = null clearFindingState() @@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun closeVisitView() { visit = null inventory = emptyList() + inventoryParentId = null fieldTypes = emptyList() selectedFieldAsset = null clearActState() @@ -132,6 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val id = visit?.id ?: return launchBusy { visit = repository.startVisit(id) + inventoryParentId = visit?.operationalArea?.id notice = "Inspección iniciada." loadActsInternal(id, selectDraft = true) loadVisitsInternal() @@ -189,25 +195,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } fun searchInventory(search: String, parentId: String? = null) { - val id = visit?.id ?: return + val currentVisit = visit ?: return launchBusy { - inventory = repository.fieldInventory(id, search, parentId).data + val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id + inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data } } fun loadFieldTypes(parentId: String? = null) { - val id = visit?.id ?: return + val currentVisit = visit ?: return launchBusy { - fieldTypes = repository.fieldTypes(id, parentId).data + val effectiveParentId = parentId ?: currentVisit.operationalArea?.id + inventoryParentId = effectiveParentId + fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data + inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data } } + private suspend fun reloadCurrentInventory(visitId: String) { + val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id + inventory = repository.fieldInventory(visitId, null, effectiveParentId).data + } + fun selectExisting(item: FieldInventoryItem) { val visitId = visit?.id ?: return launchBusy { selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) notice = "Inventario agregado a la Inspección." - inventory = repository.fieldInventory(visitId, null, null).data + reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { selectedAct = actsRepository.ensureAsset(draft.id, item.id) @@ -236,7 +251,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } if (type.familyRequired && familyId == null) { - error = "Elegí una familia técnica o la opción Otro / no catalogado." + error = "Elegí una clasificación técnica o la opción Otro / no catalogado." return } launchBusy { @@ -254,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) selectedFieldAsset = repository.createFieldAsset(visitId, request) notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." - inventory = repository.fieldInventory(visitId, null, null).data + reloadCurrentInventory(visitId) } } @@ -278,7 +293,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable." - inventory = repository.fieldInventory(visitId, null, null).data + reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) @@ -311,7 +326,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } else { "Fotografía registrada." } - inventory = repository.fieldInventory(visitId, null, null).data + reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (response.capture.readyForFinding && draft != null) { selectedAct = actsRepository.ensureAsset(draft.id, asset.id) diff --git a/api-v3/package.json b/api-v3/package.json index d39e17a..1570c1b 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index 1fba3d4..4742fb9 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -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, diff --git a/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts b/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts index a6227d2..960ec1a 100644 --- a/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts +++ b/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts @@ -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() diff --git a/api-v3/src/asset-master/dto/inventory-family-attribute.dto.ts b/api-v3/src/asset-master/dto/inventory-family-attribute.dto.ts new file mode 100644 index 0000000..316029a --- /dev/null +++ b/api-v3/src/asset-master/dto/inventory-family-attribute.dto.ts @@ -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; +} diff --git a/api-v3/src/asset-master/dto/update-inventory-technical-values.dto.ts b/api-v3/src/asset-master/dto/update-inventory-technical-values.dto.ts new file mode 100644 index 0000000..83c2bde --- /dev/null +++ b/api-v3/src/asset-master/dto/update-inventory-technical-values.dto.ts @@ -0,0 +1,6 @@ +import { IsObject } from 'class-validator'; + +export class UpdateInventoryTechnicalValuesDto { + @IsObject() + values!: Record; +} diff --git a/api-v3/src/asset-master/inventory-family-catalog.controller.ts b/api-v3/src/asset-master/inventory-family-catalog.controller.ts index 88708c2..1e03d3e 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.controller.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.controller.ts @@ -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( diff --git a/api-v3/src/asset-master/inventory-family-catalog.service.ts b/api-v3/src/asset-master/inventory-family-catalog.service.ts index c6d7c93..7d48fe9 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.service.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.service.ts @@ -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, - 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, + 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,afterData:after as unknown as Record, + 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 { - 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 { + ):Promise { + 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 { + 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; + } + + private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise { + 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(); 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'); + } } diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts index 9424d9f..c5d155b 100644 --- a/api-v3/src/asset-master/inventory-structure.service.ts +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -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, 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]]; 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 { 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); } diff --git a/api-v3/src/asset-master/inventory-technical-values.controller.ts b/api-v3/src/asset-master/inventory-technical-values.controller.ts new file mode 100644 index 0000000..18aee2b --- /dev/null +++ b/api-v3/src/asset-master/inventory-technical-values.controller.ts @@ -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); + } +} diff --git a/api-v3/src/asset-master/inventory-technical-values.service.ts b/api-v3/src/asset-master/inventory-technical-values.service.ts new file mode 100644 index 0000000..d01f663 --- /dev/null +++ b/api-v3/src/asset-master/inventory-technical-values.service.ts @@ -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={}; + 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}`}); + } +} diff --git a/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts b/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts new file mode 100644 index 0000000..8b2154d --- /dev/null +++ b/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts @@ -0,0 +1,226 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F6SolidInventoryModel1790091000000 implements MigrationInterface { + name = 'F6SolidInventoryModel1790091000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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) + `); + } +} diff --git a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts index 5c65d26..d49ba25 100644 --- a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts +++ b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts @@ -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, diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index ad1d6ad..a1ef657 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -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) { diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index d406061..0d0d043 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -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'; \ No newline at end of file diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 048c707..21615db 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -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'); +}); \ No newline at end of file diff --git a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts index 9c36c80..69a447c 100644 --- a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts +++ b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts @@ -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/); }); diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index 3eab7d9..1a89ff6 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -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/); -}); +}); \ No newline at end of file diff --git a/api-v3/test/unit/f6-solid-inventory-contract.test.ts b/api-v3/test/unit/f6-solid-inventory-contract.test.ts new file mode 100644 index 0000000..8888132 --- /dev/null +++ b/api-v3/test/unit/f6-solid-inventory-contract.test.ts @@ -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\)/); +}); diff --git a/web-v2/package.json b/web-v2/package.json index 6fbef6a..594fc31 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.22.0-1", + "version": "0.23.0-1", "private": true, "type": "module", "engines": { @@ -24,4 +24,4 @@ "typescript": "^5.9.0", "vite": "^7.0.0" } -} +} \ No newline at end of file diff --git a/web-v2/src/features/assets/AssetFindingCatalogPanel.tsx b/web-v2/src/features/assets/AssetFindingCatalogPanel.tsx index c7f8ef2..09f0acc 100644 --- a/web-v2/src/features/assets/AssetFindingCatalogPanel.tsx +++ b/web-v2/src/features/assets/AssetFindingCatalogPanel.tsx @@ -1,59 +1,98 @@ import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { useAuth } from '../../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Icon } from '../../components/Icon'; -import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api'; -import type { FindingCatalogAssetSelection } from '../../lib/api'; +import { + getInventoryFamilyFindings, + getInventoryTechnicalValues, +} from '../../lib/inventoryStructureApi'; +import type { + InventoryFamilyFinding, + InventoryTechnicalValues, +} from '../../lib/inventoryStructureApi'; +import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel'; export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) { - const [selection, setSelection] = useState(null); - const [enabled, setEnabled] = useState>(new Set()); - const [reason, setReason] = useState(''); + const { hasPermission } = useAuth(); + const canEditTechnical = hasPermission('assets.update'); + const [technical, setTechnical] = useState(null); + const [items, setItems] = useState([]); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const [unsupported, setUnsupported] = useState(false); const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => { - setSelection(loaded); - setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id))); - }); - - useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]); + useEffect(() => { + let active = true; + setLoading(true); + setUnsupported(false); + setError(''); + getInventoryTechnicalValues(assetId) + .then(async (loaded) => { + const findings = await getInventoryFamilyFindings(loaded.family.id); + if (!active) return; + setTechnical(loaded); + setItems(findings.items); + }) + .catch((requestError) => { + if (!active) return; + const message = errorMessage(requestError); + if (message.includes('no tiene clasificación técnica')) setUnsupported(true); + else setError(message); + }) + .finally(() => active && setLoading(false)); + return () => { active = false; }; + }, [assetId]); const visible = useMemo(() => { const needle = search.trim().toLocaleLowerCase(); - return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName] - .some((value) => value.toLocaleLowerCase().includes(needle))) ?? []; - }, [selection, search]); + if (!needle) return items; + return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? ''] + .some((value) => value.toLocaleLowerCase().includes(needle))); + }, [items, search]); - const toggle = (id: string) => setEnabled((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); else next.add(id); - return next; - }); - - const save = async () => { - setSaving(true); setError(''); setSuccess(''); - try { - const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason }); - setSelection(saved); - setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id))); - setReason(''); - setSuccess('Subconjunto de hallazgos actualizado para este objeto.'); - } catch (requestError) { setError(errorMessage(requestError)); } - finally { setSaving(false); } - }; - - if (loading) return
; - if (!selection) return {error || 'No se pudo cargar la configuración.'}; - - return
-
HALLAZGOS APLICABLES

{selection.asset.name}

Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.

{enabled.size} habilitados
- {error && {error}}{success && {success}} - {!selection.typeConfigured &&

El tipo técnico todavía no tiene un catálogo restringido. Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en “Catálogo de hallazgos”.

} - -
{visible.map((item) => )}
- {canManage && <>