F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)

Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
This commit is contained in:
2026-09-10 16:06:48 -03:00
committed by GitHub
parent ce2a85a26a
commit 7be5d89204
29 changed files with 1330 additions and 218 deletions
@@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson {
id: string;
@@ -312,7 +313,7 @@ export class InspectionVisitsService {
}
const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -382,29 +383,24 @@ export class InspectionVisitsService {
? visit.operatorCompanyId
: dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = contextTouched
? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
await this.requireAsset(manager, nextScope);
if (contextTouched && nextAreaId && nextCompanyId) {
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
if (nextAreaId && nextScope) {
await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
await this.assertCurrentAssetsInScope(manager, id, nextScope);
}
if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) {
visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId;
visit.scopeAssetId = nextAreaId;
} else if (dto.scopeAssetId !== undefined) {
}
if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId;
}
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id);
@@ -463,11 +459,10 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
});
}
await this.assertAssetsMatchContext(
await this.assertAssetsInScope(
manager,
additions,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
);
for (const assetId of additions) {
await manager.query(`
@@ -629,11 +624,10 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
}
await this.assertAssetsMatchContext(
await this.assertAssetsInScope(
manager,
[assetId],
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
);
await manager.query(`
UPDATE inspection_visit_assets
@@ -920,6 +914,7 @@ export class InspectionVisitsService {
manager: EntityManager,
operationalAreaId: string | null,
operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) {
@@ -938,8 +933,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id
AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
) AS "activeOperatorRelation"
FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -951,7 +946,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId])) as Array<{
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
areaId: string;
companyId: string;
activeOperatorRelation: boolean;
@@ -970,44 +965,6 @@ export class InspectionVisitsService {
}
}
private async assertAssetsMatchContext(
manager: EntityManager,
assetIds: string[],
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM assets asset
WHERE asset.id = ANY($1::uuid[])
AND (
asset.operational_area_id IS DISTINCT FROM $2::uuid
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
)
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
});
}
}
private async assertCurrentAssetsMatchContext(
manager: EntityManager,
visitId: string,
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
await this.assertAssetsMatchContext(
manager,
await this.activeAssetIds(manager, visitId),
operationalAreaId,
operatorCompanyId,
);
}
private async appendAssetEvent(
manager: EntityManager,
visitId: string,
@@ -1041,7 +998,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);
const generation = visit.checklistGeneration + 1;
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
@@ -1106,14 +1063,25 @@ export class InspectionVisitsService {
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED'
AND (
asset.id=$4::uuid
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=$4::uuid LIMIT 1
)
)
ORDER BY finding.created_at, finding.id
`, [
visit.id,
generation,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
visit.plannedStartAt.toISOString().slice(0, 10),
]);
@@ -1295,7 +1263,12 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
await this.validatePlanningContext(
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
if (!visit.scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1350,13 +1323,10 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable',
});
}
// F6.1: Inventory membership is physical (Yacimiento scope).
// Company is temporal Area context and must never be reconstructed from
// assets.operator_company_id, which is only a creation/historical snapshot.
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
await this.assertCurrentAssetsMatchContext(
manager,
visit.id,
visit.operationalAreaId,
visit.operatorCompanyId,
);
}
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {