Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7388aa3196 | ||
|
|
77ec954067 | ||
|
|
6d3f116ff3 | ||
|
|
fa99b5f731 | ||
|
|
ae1ea3a1dd | ||
|
|
e4f1543651 | ||
|
|
ea6abe4a82 | ||
|
|
b3176679d8 | ||
|
|
7be5d89204 |
@@ -0,0 +1,26 @@
|
|||||||
|
name: Inspection planning smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'api-v3/src/inspection-visits/**'
|
||||||
|
- 'api-v3/src/database/migrations/**'
|
||||||
|
- 'api-v3/src/reference-data/**'
|
||||||
|
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
|
||||||
|
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
|
||||||
|
- 'web-v2/src/layout/AppLayout.tsx'
|
||||||
|
- 'scripts/ci-inspection-planning-smoke.sh'
|
||||||
|
- '.github/workflows/inspection-planning-smoke.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-inspection:
|
||||||
|
name: F6.1 · real inspection create
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Rehearse hierarchy and create a real Inspection
|
||||||
|
run: bash scripts/ci-inspection-planning-smoke.sh
|
||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 23
|
versionCode = 24
|
||||||
versionName = "0.15.1"
|
versionName = "0.15.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@drawable/ic_mendoza_launcher"
|
android:icon="@drawable/ic_mendoza_launcher_exact"
|
||||||
android:roundIcon="@drawable/ic_mendoza_launcher"
|
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.DHInspeccion"
|
android:theme="@style/Theme.DHInspeccion"
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun openVisit(id: String) = launchBusy {
|
fun openVisit(id: String) = launchBusy {
|
||||||
visit = repository.visit(id)
|
visit = repository.visit(id)
|
||||||
inventory = emptyList()
|
inventory = emptyList()
|
||||||
inventoryParentId = visit?.operationalArea?.id
|
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||||
fieldTypes = emptyList()
|
fieldTypes = emptyList()
|
||||||
selectedFieldAsset = null
|
selectedFieldAsset = null
|
||||||
clearFindingState()
|
clearFindingState()
|
||||||
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val id = visit?.id ?: return
|
val id = visit?.id ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
visit = repository.startVisit(id)
|
visit = repository.startVisit(id)
|
||||||
inventoryParentId = visit?.operationalArea?.id
|
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||||
notice = "Inspección iniciada."
|
notice = "Inspección iniciada."
|
||||||
loadActsInternal(id, selectDraft = true)
|
loadActsInternal(id, selectDraft = true)
|
||||||
loadVisitsInternal()
|
loadVisitsInternal()
|
||||||
@@ -197,7 +197,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun searchInventory(search: String, parentId: String? = null) {
|
fun searchInventory(search: String, parentId: String? = null) {
|
||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
|
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
||||||
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
|
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun loadFieldTypes(parentId: String? = null) {
|
fun loadFieldTypes(parentId: String? = null) {
|
||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
|
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
||||||
inventoryParentId = effectiveParentId
|
inventoryParentId = effectiveParentId
|
||||||
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
||||||
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
||||||
@@ -213,7 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadCurrentInventory(visitId: String) {
|
private suspend fun reloadCurrentInventory(visitId: String) {
|
||||||
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
|
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||||
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ data class VisitDetail(
|
|||||||
val code: String,
|
val code: String,
|
||||||
val objective: String? = null,
|
val objective: String? = null,
|
||||||
val status: String,
|
val status: String,
|
||||||
|
val scopeAsset: AssetSummary? = null,
|
||||||
val operationalArea: AssetSummary? = null,
|
val operationalArea: AssetSummary? = null,
|
||||||
val operatorCompany: AssetSummary? = null,
|
val operatorCompany: AssetSummary? = null,
|
||||||
val leadInspector: PersonSummary? = null,
|
val leadInspector: PersonSummary? = null,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
|||||||
@Test
|
@Test
|
||||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||||
assertEquals(23, BuildConfig.VERSION_CODE)
|
assertEquals(24, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.15.1-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { IsISO8601, IsUUID } from 'class-validator';
|
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreateInspectionVisitDto {
|
export class CreateInspectionVisitDto {
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operationalAreaId!: string;
|
operationalAreaId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
|
||||||
|
* romper clientes Android anteriores, que todavía abren una Inspección a
|
||||||
|
* nivel Área; la WEB F6.1 siempre lo envía.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
scopeAssetId?: string;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operatorCompanyId!: string;
|
operatorCompanyId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
|
|||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||||
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||||
import type { InspectionVisitView } from './inspection-visits.service';
|
import type {
|
||||||
|
InspectionChecklistItemKind,
|
||||||
|
InspectionVisitView,
|
||||||
|
} from './inspection-visits.service';
|
||||||
import { InspectionVisitsService } from './inspection-visits.service';
|
import { InspectionVisitsService } from './inspection-visits.service';
|
||||||
|
|
||||||
|
type F4ChecklistClassification = {
|
||||||
|
findingId: string;
|
||||||
|
itemKind: InspectionChecklistItemKind;
|
||||||
|
referenceOn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capa activa F4 sobre el servicio de Inspecciones.
|
* Capa activa F4 sobre el servicio de Inspecciones.
|
||||||
*
|
*
|
||||||
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
|
* El checklist persistido es una generación histórica append-only. F4 necesita
|
||||||
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya
|
* mostrar la clasificación vigente de controles, pero una lectura nunca debe
|
||||||
* no expone título independiente ni fecha prevista de fin.
|
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica
|
||||||
|
* sólo a la vista devuelta por la API.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class F4InspectionVisitsService extends InspectionVisitsService {
|
export class F4InspectionVisitsService extends InspectionVisitsService {
|
||||||
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async getById(id: string): Promise<InspectionVisitView> {
|
override async getById(id: string): Promise<InspectionVisitView> {
|
||||||
await this.reclassifyCurrentChecklist(id);
|
const visit = await super.getById(id);
|
||||||
return this.normalizeChecklist(await super.getById(id));
|
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||||
}
|
}
|
||||||
|
|
||||||
override async create(
|
override async create(
|
||||||
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionVisitView> {
|
): Promise<InspectionVisitView> {
|
||||||
const created = await super.create(dto, principal, request);
|
const visit = await super.create(dto, principal, request);
|
||||||
await this.reclassifyCurrentChecklist(created.id);
|
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||||
return this.normalizeChecklist(await super.getById(created.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async generateChecklist(
|
override async generateChecklist(
|
||||||
@@ -51,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionVisitView> {
|
): Promise<InspectionVisitView> {
|
||||||
await super.generateChecklist(id, principal, request);
|
const visit = await super.generateChecklist(id, principal, request);
|
||||||
await this.reclassifyCurrentChecklist(id);
|
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||||
return this.normalizeChecklist(await super.getById(id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeChecklist<T>(visit: T): T {
|
private normalizeChecklist<T>(visit: T): T {
|
||||||
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
|
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
|
||||||
const view = visit as T & InspectionVisitView;
|
const view = visit as T & InspectionVisitView;
|
||||||
|
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
|
||||||
|
const actionableAssetIds = new Set(
|
||||||
|
items
|
||||||
|
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
|
||||||
|
.map((item) => item.asset.id),
|
||||||
|
);
|
||||||
view.checklist = {
|
view.checklist = {
|
||||||
...view.checklist,
|
...view.checklist,
|
||||||
|
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
|
||||||
companyOverdue: 0,
|
companyOverdue: 0,
|
||||||
items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'),
|
verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
|
||||||
|
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
|
||||||
|
actionableAssets: actionableAssetIds.size,
|
||||||
|
items,
|
||||||
};
|
};
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
|
/**
|
||||||
await this.f4DataSource.transaction(async (manager) => {
|
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
|
||||||
const [visit] = await manager.query(`
|
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de
|
||||||
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
|
* campo sólo importan próximos controles y verificaciones no resueltas.
|
||||||
FROM inspection_visits
|
*/
|
||||||
WHERE id=$1
|
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
|
||||||
FOR UPDATE
|
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
|
||||||
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
|
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
|
||||||
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
|
if (findingIds.length === 0) return visit;
|
||||||
|
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
|
||||||
|
|
||||||
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
|
const classifications = (await this.f4DataSource.query(`
|
||||||
await manager.query(`
|
SELECT
|
||||||
UPDATE inspection_visit_checklist_items item
|
finding.id AS "findingId",
|
||||||
SET
|
CASE
|
||||||
item_kind = CASE
|
WHEN finding.status='OPEN'
|
||||||
WHEN finding.status='OPEN'
|
AND finding.next_control_on IS NOT NULL
|
||||||
AND finding.next_control_on IS NOT NULL
|
AND finding.next_control_on < $2::date
|
||||||
AND finding.next_control_on < $3::date
|
AND COALESCE((
|
||||||
AND COALESCE((
|
SELECT verification.outcome
|
||||||
SELECT verification.outcome
|
FROM inspection_finding_verification_visits verification
|
||||||
FROM inspection_finding_verification_visits verification
|
WHERE verification.finding_id=finding.id
|
||||||
WHERE verification.finding_id=finding.id
|
AND verification.outcome IS NOT NULL
|
||||||
AND verification.outcome IS NOT NULL
|
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
||||||
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
verification.created_at DESC,
|
||||||
verification.created_at DESC,
|
verification.id DESC
|
||||||
verification.id DESC
|
LIMIT 1
|
||||||
LIMIT 1
|
), '') <> 'RESOLVED'
|
||||||
), '') <> 'RESOLVED'
|
THEN 'VERIFICATION_OVERDUE'
|
||||||
THEN 'VERIFICATION_OVERDUE'
|
WHEN finding.status='OPEN'
|
||||||
WHEN finding.status='OPEN'
|
AND finding.next_control_on IS NOT NULL
|
||||||
AND finding.next_control_on IS NOT NULL
|
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
|
||||||
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
|
AND COALESCE((
|
||||||
AND COALESCE((
|
SELECT verification.outcome
|
||||||
SELECT verification.outcome
|
FROM inspection_finding_verification_visits verification
|
||||||
FROM inspection_finding_verification_visits verification
|
WHERE verification.finding_id=finding.id
|
||||||
WHERE verification.finding_id=finding.id
|
AND verification.outcome IS NOT NULL
|
||||||
AND verification.outcome IS NOT NULL
|
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
||||||
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
verification.created_at DESC,
|
||||||
verification.created_at DESC,
|
verification.id DESC
|
||||||
verification.id DESC
|
LIMIT 1
|
||||||
LIMIT 1
|
), '') <> 'RESOLVED'
|
||||||
), '') <> 'RESOLVED'
|
THEN 'UPCOMING_CONTROL'
|
||||||
THEN 'UPCOMING_CONTROL'
|
ELSE 'ANTECEDENT'
|
||||||
ELSE 'ANTECEDENT'
|
END AS "itemKind",
|
||||||
END,
|
CASE
|
||||||
reference_on = CASE
|
WHEN finding.status='OPEN'
|
||||||
WHEN finding.status='OPEN'
|
AND finding.next_control_on IS NOT NULL
|
||||||
AND finding.next_control_on IS NOT NULL
|
AND COALESCE((
|
||||||
AND COALESCE((
|
SELECT verification.outcome
|
||||||
SELECT verification.outcome
|
FROM inspection_finding_verification_visits verification
|
||||||
FROM inspection_finding_verification_visits verification
|
WHERE verification.finding_id=finding.id
|
||||||
WHERE verification.finding_id=finding.id
|
AND verification.outcome IS NOT NULL
|
||||||
AND verification.outcome IS NOT NULL
|
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
||||||
ORDER BY verification.result_recorded_at DESC NULLS LAST,
|
verification.created_at DESC,
|
||||||
verification.created_at DESC,
|
verification.id DESC
|
||||||
verification.id DESC
|
LIMIT 1
|
||||||
LIMIT 1
|
), '') <> 'RESOLVED'
|
||||||
), '') <> 'RESOLVED'
|
THEN finding.next_control_on::text
|
||||||
THEN finding.next_control_on
|
ELSE NULL
|
||||||
ELSE NULL
|
END AS "referenceOn"
|
||||||
END
|
FROM inspection_findings finding
|
||||||
FROM inspection_findings finding
|
WHERE finding.id=ANY($1::uuid[])
|
||||||
WHERE item.visit_id=$1
|
`, [findingIds, targetDate])) as F4ChecklistClassification[];
|
||||||
AND item.generation_number=$2
|
|
||||||
AND finding.id=item.finding_id
|
const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
|
||||||
`, [visitId, visit.generation, targetDate]);
|
visit.checklist = {
|
||||||
});
|
...visit.checklist,
|
||||||
|
items: visit.checklist.items.map((item) => {
|
||||||
|
const classification = byFinding.get(item.findingId);
|
||||||
|
return classification
|
||||||
|
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
|
||||||
|
: item;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return visit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export class FieldFindingsService {
|
|||||||
visit.id AS "visitId",
|
visit.id AS "visitId",
|
||||||
visit.code AS "visitCode",
|
visit.code AS "visitCode",
|
||||||
visit.status AS "visitStatus",
|
visit.status AS "visitStatus",
|
||||||
|
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
|
||||||
visit.operational_area_id AS "areaId",
|
visit.operational_area_id AS "areaId",
|
||||||
area.code AS "areaCode",
|
area.code AS "areaCode",
|
||||||
area.name AS "areaName",
|
area.name AS "areaName",
|
||||||
@@ -139,8 +140,7 @@ export class FieldFindingsService {
|
|||||||
asset_type.code AS "assetTypeCode",
|
asset_type.code AS "assetTypeCode",
|
||||||
asset.inventory_family_id AS "inventoryFamilyId",
|
asset.inventory_family_id AS "inventoryFamilyId",
|
||||||
(
|
(
|
||||||
asset.operational_area_id=visit.operational_area_id
|
asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
|
||||||
OR asset.id=visit.operational_area_id
|
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
||||||
@@ -148,9 +148,11 @@ export class FieldFindingsService {
|
|||||||
SELECT parent.id,parent.parent_id
|
SELECT parent.id,parent.parent_id
|
||||||
FROM assets parent JOIN ancestors child ON parent.id=child.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
|
SELECT 1 FROM ancestors
|
||||||
|
WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
|
||||||
|
LIMIT 1
|
||||||
)
|
)
|
||||||
) AS "insideArea",
|
) AS "insideScope",
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM area_company_relations relation
|
FROM area_company_relations relation
|
||||||
@@ -213,6 +215,7 @@ export class FieldFindingsService {
|
|||||||
visitId: string;
|
visitId: string;
|
||||||
visitCode: string;
|
visitCode: string;
|
||||||
visitStatus: string;
|
visitStatus: string;
|
||||||
|
scopeAssetId: string | null;
|
||||||
areaId: string | null;
|
areaId: string | null;
|
||||||
areaCode: string | null;
|
areaCode: string | null;
|
||||||
areaName: string | null;
|
areaName: string | null;
|
||||||
@@ -222,7 +225,7 @@ export class FieldFindingsService {
|
|||||||
assetId: string | null;
|
assetId: string | null;
|
||||||
assetTypeCode: string | null;
|
assetTypeCode: string | null;
|
||||||
inventoryFamilyId: string | null;
|
inventoryFamilyId: string | null;
|
||||||
insideArea: boolean;
|
insideScope: boolean;
|
||||||
operatorRelationValid: boolean;
|
operatorRelationValid: boolean;
|
||||||
assigned: boolean;
|
assigned: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -278,10 +281,10 @@ export class FieldFindingsService {
|
|||||||
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!row.insideArea) {
|
if (!row.insideScope) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
||||||
message: 'El Inventario no pertenece al Área de esta inspección',
|
message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!row.selected) {
|
if (!row.selected) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface MobileVisitContext {
|
|||||||
code: string;
|
code: string;
|
||||||
status: string;
|
status: string;
|
||||||
areaId: string;
|
areaId: string;
|
||||||
|
scopeAssetId: string;
|
||||||
areaCode: string;
|
areaCode: string;
|
||||||
areaName: string;
|
areaName: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -80,12 +81,12 @@ export class FieldInventoryService {
|
|||||||
const context = await this.requireVisitContext(visitId, principal, false);
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
||||||
|
|
||||||
// Empresa is visit context, never physical ownership. Search by Area ancestry.
|
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.
|
||||||
const args: unknown[] = [context.areaId, visitId];
|
const args: unknown[] = [context.scopeAssetId, visitId];
|
||||||
const conditions = [
|
const conditions = [
|
||||||
"asset.information_status <> 'INACTIVE'",
|
"asset.information_status <> 'INACTIVE'",
|
||||||
`(
|
`(
|
||||||
asset.operational_area_id = $1::uuid
|
asset.id = $1::uuid
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
||||||
@@ -198,7 +199,7 @@ export class FieldInventoryService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
) {
|
) {
|
||||||
const context = await this.requireVisitContext(visitId, principal, false);
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
const effectiveParentId = parentId ?? context.areaId;
|
const effectiveParentId = parentId ?? context.scopeAssetId;
|
||||||
const parent = await this.requireParentInContext(effectiveParentId, context);
|
const parent = await this.requireParentInContext(effectiveParentId, context);
|
||||||
|
|
||||||
const data = await this.dataSource.query(`
|
const data = await this.dataSource.query(`
|
||||||
@@ -272,7 +273,7 @@ export class FieldInventoryService {
|
|||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
) {
|
) {
|
||||||
const context = await this.requireVisitContext(visitId, principal, true);
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
const parentId = dto.parentId ?? context.areaId;
|
const parentId = dto.parentId ?? context.scopeAssetId;
|
||||||
await this.requireParentInContext(parentId, context);
|
await this.requireParentInContext(parentId, context);
|
||||||
|
|
||||||
const capturedAt = new Date(dto.deviceCapturedAt);
|
const capturedAt = new Date(dto.deviceCapturedAt);
|
||||||
@@ -412,6 +413,7 @@ export class FieldInventoryService {
|
|||||||
visit.id,
|
visit.id,
|
||||||
visit.code,
|
visit.code,
|
||||||
visit.status,
|
visit.status,
|
||||||
|
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
|
||||||
visit.operational_area_id AS "areaId",
|
visit.operational_area_id AS "areaId",
|
||||||
area.code AS "areaCode",
|
area.code AS "areaCode",
|
||||||
area.name AS "areaName",
|
area.name AS "areaName",
|
||||||
@@ -485,6 +487,7 @@ export class FieldInventoryService {
|
|||||||
private publicContext(context: MobileVisitContext) {
|
private publicContext(context: MobileVisitContext) {
|
||||||
return {
|
return {
|
||||||
inspection: { id: context.id, code: context.code, status: context.status },
|
inspection: { id: context.id, code: context.code, status: context.status },
|
||||||
|
scopeAssetId: context.scopeAssetId,
|
||||||
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
|
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
|
||||||
operatorCompany: {
|
operatorCompany: {
|
||||||
id: context.companyId,
|
id: context.companyId,
|
||||||
@@ -503,7 +506,6 @@ export class FieldInventoryService {
|
|||||||
asset.asset_type_id AS "typeId",
|
asset.asset_type_id AS "typeId",
|
||||||
(
|
(
|
||||||
asset.id=$2::uuid
|
asset.id=$2::uuid
|
||||||
OR asset.operational_area_id=$2::uuid
|
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
||||||
@@ -512,16 +514,16 @@ export class FieldInventoryService {
|
|||||||
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
||||||
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
|
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
|
||||||
)
|
)
|
||||||
) AS "insideArea"
|
) AS "insideScope"
|
||||||
FROM assets asset
|
FROM assets asset
|
||||||
WHERE asset.id = $1::uuid
|
WHERE asset.id = $1::uuid
|
||||||
AND asset.information_status <> 'INACTIVE'
|
AND asset.information_status <> 'INACTIVE'
|
||||||
`, [parentId, context.areaId])) as Array<{
|
`, [parentId, context.scopeAssetId])) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
typeId: string;
|
typeId: string;
|
||||||
insideArea: boolean;
|
insideScope: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@@ -530,10 +532,10 @@ export class FieldInventoryService {
|
|||||||
message: 'La ubicación padre no existe',
|
message: 'La ubicación padre no existe',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!parent.insideArea) {
|
if (!parent.insideScope) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
|
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
|
||||||
message: 'La ubicación padre no pertenece al Área de la inspección',
|
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return parent;
|
return parent;
|
||||||
@@ -545,7 +547,7 @@ export class FieldInventoryService {
|
|||||||
asset.id,
|
asset.id,
|
||||||
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
|
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
|
||||||
(
|
(
|
||||||
asset.operational_area_id=$2::uuid
|
asset.id=$2::uuid
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
WITH RECURSIVE ancestors AS (
|
WITH RECURSIVE ancestors AS (
|
||||||
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
|
||||||
@@ -554,15 +556,15 @@ export class FieldInventoryService {
|
|||||||
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
||||||
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
|
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
|
||||||
)
|
)
|
||||||
) AS "insideArea"
|
) AS "insideScope"
|
||||||
FROM assets asset
|
FROM assets asset
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
WHERE asset.id = $1::uuid
|
WHERE asset.id = $1::uuid
|
||||||
AND asset.information_status <> 'INACTIVE'
|
AND asset.information_status <> 'INACTIVE'
|
||||||
`, [assetId, context.areaId])) as Array<{
|
`, [assetId, context.scopeAssetId])) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
eligibleTarget: boolean;
|
eligibleTarget: boolean;
|
||||||
insideArea: boolean;
|
insideScope: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -571,10 +573,10 @@ export class FieldInventoryService {
|
|||||||
message: 'Registro de Inventario no encontrado',
|
message: 'Registro de Inventario no encontrado',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!asset.insideArea) {
|
if (!asset.insideScope) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
|
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
|
||||||
message: 'El registro no pertenece al Área de esta inspección',
|
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!asset.eligibleTarget) {
|
if (!asset.eligibleTarget) {
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities';
|
||||||
|
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||||
|
import { nextInspectionVisitCode } from './inspection-visit-code';
|
||||||
|
import { validateInspectionPlanningScope } from './inspection-planning-scope';
|
||||||
|
import { InspectionVisitsService } from './inspection-visits.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance:
|
||||||
|
* Departamento → Área → Yacimiento. La Operadora no es un padre físico; se
|
||||||
|
* valida contra la relación temporal vigente del Área para la fecha planificada.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class InspectionPlanningCreateService {
|
||||||
|
private readonly logger = new Logger(InspectionPlanningCreateService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly visits: InspectionVisitsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateInspectionVisitDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const scopeAssetId = dto.scopeAssetId;
|
||||||
|
if (!scopeAssetId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_YACIMIENTO_REQUIRED',
|
||||||
|
message: 'Seleccioná el Yacimiento de la Inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const plannedStartAt = new Date(dto.plannedStartAt);
|
||||||
|
if (!Number.isFinite(plannedStartAt.getTime())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_START_DATE_REQUIRED',
|
||||||
|
message: 'Indicá una fecha y hora prevista de inicio válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stage = 'transaction:start';
|
||||||
|
try {
|
||||||
|
const visitId = await this.dataSource.transaction(async (manager) => {
|
||||||
|
stage = 'scope:validate';
|
||||||
|
await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId);
|
||||||
|
|
||||||
|
stage = 'operator:validate';
|
||||||
|
const [context] = await manager.query(`
|
||||||
|
SELECT relation.id
|
||||||
|
FROM area_company_relations relation
|
||||||
|
INNER JOIN assets area ON area.id=relation.area_id
|
||||||
|
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||||
|
INNER JOIN assets company ON company.id=relation.company_id
|
||||||
|
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||||
|
WHERE relation.area_id=$1::uuid
|
||||||
|
AND relation.company_id=$2::uuid
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_from <= $3::timestamptz
|
||||||
|
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
|
||||||
|
AND area_type.operational_role='AREA'
|
||||||
|
AND company_type.operational_role='COMPANY'
|
||||||
|
AND area.information_status<>'INACTIVE'
|
||||||
|
AND company.information_status<>'INACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>;
|
||||||
|
if (!context) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
|
||||||
|
message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stage = 'inspector:validate';
|
||||||
|
const [inspector] = await manager.query(`
|
||||||
|
SELECT user_account.id
|
||||||
|
FROM users user_account
|
||||||
|
WHERE user_account.id=$1::uuid
|
||||||
|
AND user_account.status='ACTIVE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_roles user_role
|
||||||
|
INNER JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
|
||||||
|
INNER JOIN permissions permission ON permission.id=role_permission.permission_id
|
||||||
|
WHERE user_role.user_id=user_account.id
|
||||||
|
AND permission.code='inspections.execute'
|
||||||
|
)
|
||||||
|
LIMIT 1
|
||||||
|
`, [dto.leadInspectorUserId]) as Array<{ id: string }>;
|
||||||
|
if (!inspector) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_MEMBER_INVALID',
|
||||||
|
message: 'El Inspector responsable no está activo o no tiene permiso de ejecución',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stage = 'code:next';
|
||||||
|
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||||
|
const visit = manager.getRepository(InspectionVisit).create({
|
||||||
|
code,
|
||||||
|
objective: null,
|
||||||
|
status: InspectionVisitStatus.DRAFT,
|
||||||
|
scopeAssetId,
|
||||||
|
operationalAreaId: dto.operationalAreaId,
|
||||||
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
|
leadInspectorUserId: dto.leadInspectorUserId,
|
||||||
|
plannedStartAt,
|
||||||
|
actualStartedAt: null,
|
||||||
|
actualClosedAt: null,
|
||||||
|
instructions: null,
|
||||||
|
cancellationReason: null,
|
||||||
|
checklistGeneration: 1,
|
||||||
|
checklistGeneratedAt: new Date(),
|
||||||
|
createdBy: principal.userId,
|
||||||
|
updatedBy: principal.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
stage = 'visit:save';
|
||||||
|
await manager.getRepository(InspectionVisit).save(visit);
|
||||||
|
|
||||||
|
stage = 'member:insert';
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by)
|
||||||
|
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
|
||||||
|
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
|
||||||
|
|
||||||
|
// El checklist sigue el alcance físico del Yacimiento. No usa
|
||||||
|
// assets.operator_company_id, que en F6 es sólo snapshot histórico.
|
||||||
|
stage = 'checklist:insert';
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_visit_checklist_items (
|
||||||
|
visit_id,generation_number,finding_id,asset_id,item_kind,
|
||||||
|
reference_on,finding_status,finding_code,finding_title,severity
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
$1::uuid,1,finding.id,finding.asset_id,
|
||||||
|
CASE
|
||||||
|
WHEN finding.status='OPEN'
|
||||||
|
AND finding.next_control_on IS NOT NULL
|
||||||
|
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
|
||||||
|
WHEN finding.status='OPEN'
|
||||||
|
AND finding.next_control_on IS NOT NULL
|
||||||
|
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
|
||||||
|
ELSE 'ANTECEDENT'
|
||||||
|
END,
|
||||||
|
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
|
||||||
|
finding.status,finding.code,finding.title,finding.severity
|
||||||
|
FROM inspection_findings finding
|
||||||
|
INNER JOIN assets inventory ON inventory.id=finding.asset_id
|
||||||
|
WHERE inventory.operational_area_id=$2::uuid
|
||||||
|
AND finding.status<>'VOIDED'
|
||||||
|
AND EXISTS (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT current_asset.id,current_asset.parent_id
|
||||||
|
FROM assets current_asset WHERE current_asset.id=finding.asset_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id,parent.parent_id
|
||||||
|
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
|
||||||
|
)
|
||||||
|
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY finding.created_at,finding.id
|
||||||
|
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
|
||||||
|
|
||||||
|
stage = 'checklist:assets';
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_visit_assets (
|
||||||
|
visit_id, asset_id, included, planning_source, added_by
|
||||||
|
)
|
||||||
|
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
|
||||||
|
FROM inspection_visit_checklist_items item
|
||||||
|
WHERE item.visit_id=$1::uuid
|
||||||
|
AND item.generation_number=1
|
||||||
|
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
|
||||||
|
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
|
||||||
|
included=true,
|
||||||
|
planning_source=CASE
|
||||||
|
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
|
||||||
|
ELSE 'AUTOMATIC'
|
||||||
|
END,
|
||||||
|
exclusion_reason=NULL,
|
||||||
|
excluded_by=NULL,
|
||||||
|
excluded_at=NULL,
|
||||||
|
added_by=EXCLUDED.added_by,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [visit.id, principal.userId]);
|
||||||
|
|
||||||
|
stage = 'checklist:asset-events';
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_visit_asset_events (
|
||||||
|
visit_id, asset_id, event_type, reason, actor_user_id, metadata
|
||||||
|
)
|
||||||
|
SELECT DISTINCT
|
||||||
|
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
|
||||||
|
jsonb_build_object('generation',1,'source','F6.1_CREATE')
|
||||||
|
FROM inspection_visit_checklist_items item
|
||||||
|
WHERE item.visit_id=$1::uuid
|
||||||
|
AND item.generation_number=1
|
||||||
|
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visit_asset_events event
|
||||||
|
WHERE event.visit_id=$1::uuid
|
||||||
|
AND event.asset_id=item.asset_id
|
||||||
|
AND event.event_type='AUTO_INCLUDED'
|
||||||
|
)
|
||||||
|
`, [visit.id, principal.userId]);
|
||||||
|
|
||||||
|
stage = 'audit:record';
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_VISIT_CREATED,
|
||||||
|
entityType: 'inspection_visit',
|
||||||
|
entityId: visit.id,
|
||||||
|
afterData: {
|
||||||
|
id: visit.id,
|
||||||
|
code,
|
||||||
|
status: visit.status,
|
||||||
|
departmentHierarchy: 'Departamento → Área → Yacimiento',
|
||||||
|
operationalAreaId: dto.operationalAreaId,
|
||||||
|
scopeAssetId,
|
||||||
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
|
plannedStartAt: plannedStartAt.toISOString(),
|
||||||
|
leadInspectorUserId: dto.leadInspectorUserId,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return visit.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
stage = 'view:load';
|
||||||
|
return await this.visits.getById(visitId);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_CODE_ALREADY_EXISTS',
|
||||||
|
message: 'El identificador de Inspección ya existe; volvé a intentar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const technical = error instanceof Error
|
||||||
|
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
|
||||||
|
: String(error);
|
||||||
|
this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export interface InspectionPlanningHierarchyItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InspectionPlanningHierarchyService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT department.id, department.code, department.name
|
||||||
|
FROM assets department
|
||||||
|
INNER JOIN asset_types type ON type.id=department.asset_type_id
|
||||||
|
WHERE lower(type.code)='departamento'
|
||||||
|
AND type.is_active=true
|
||||||
|
AND department.information_status<>'INACTIVE'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM assets area
|
||||||
|
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||||
|
WHERE area.parent_id=department.id
|
||||||
|
AND lower(area_type.code)='area'
|
||||||
|
AND area.information_status<>'INACTIVE'
|
||||||
|
)
|
||||||
|
ORDER BY department.name, department.code
|
||||||
|
`) as InspectionPlanningHierarchyItem[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||||
|
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT area.id, area.code, area.name
|
||||||
|
FROM assets area
|
||||||
|
INNER JOIN asset_types type ON type.id=area.asset_type_id
|
||||||
|
WHERE area.parent_id=$1::uuid
|
||||||
|
AND lower(type.code)='area'
|
||||||
|
AND type.is_active=true
|
||||||
|
AND area.information_status<>'INACTIVE'
|
||||||
|
ORDER BY area.name, area.code
|
||||||
|
`, [departmentId]) as InspectionPlanningHierarchyItem[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||||
|
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT yacimiento.id, yacimiento.code, yacimiento.name
|
||||||
|
FROM assets yacimiento
|
||||||
|
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||||
|
WHERE yacimiento.parent_id=$1::uuid
|
||||||
|
AND lower(type.code)='yacimiento'
|
||||||
|
AND type.is_active=true
|
||||||
|
AND yacimiento.information_status<>'INACTIVE'
|
||||||
|
ORDER BY yacimiento.name, yacimiento.code
|
||||||
|
`, [areaId]) as InspectionPlanningHierarchyItem[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async operatorsForArea(
|
||||||
|
areaId: string,
|
||||||
|
at?: string,
|
||||||
|
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||||
|
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
|
||||||
|
const effectiveAt = at ? new Date(at) : new Date();
|
||||||
|
if (!Number.isFinite(effectiveAt.getTime())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_PLANNING_DATE_INVALID',
|
||||||
|
message: 'La fecha de planificación no es válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT DISTINCT company.id, company.code, company.name
|
||||||
|
FROM area_company_relations relation
|
||||||
|
INNER JOIN assets company ON company.id=relation.company_id
|
||||||
|
INNER JOIN asset_types type ON type.id=company.asset_type_id
|
||||||
|
WHERE relation.area_id=$1::uuid
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_from <= $2::timestamptz
|
||||||
|
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
|
||||||
|
AND type.operational_role='COMPANY'
|
||||||
|
AND type.is_active=true
|
||||||
|
AND company.information_status<>'INACTIVE'
|
||||||
|
ORDER BY company.name, company.code
|
||||||
|
`, [areaId, effectiveAt]) as InspectionPlanningHierarchyItem[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT 1
|
||||||
|
FROM assets asset
|
||||||
|
INNER JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=$1::uuid
|
||||||
|
AND lower(type.code)=lower($2)
|
||||||
|
AND type.is_active=true
|
||||||
|
AND asset.information_status<>'INACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`, [id, typeCode]) as Array<{ '?column?': number }>;
|
||||||
|
if (!row) {
|
||||||
|
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
|
||||||
|
* context while scopeAssetId identifies the Yacimiento inspected inside it.
|
||||||
|
* Legacy/mobile callers may still use the Area itself as scope until Android
|
||||||
|
* supplies a Yacimiento explicitly.
|
||||||
|
*/
|
||||||
|
export async function validateInspectionPlanningScope(
|
||||||
|
manager: EntityManager,
|
||||||
|
operationalAreaId: string,
|
||||||
|
scopeAssetId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (scopeAssetId === operationalAreaId) return;
|
||||||
|
|
||||||
|
const [scope] = await manager.query(`
|
||||||
|
SELECT 1
|
||||||
|
FROM assets yacimiento
|
||||||
|
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||||
|
WHERE yacimiento.id=$1::uuid
|
||||||
|
AND yacimiento.parent_id=$2::uuid
|
||||||
|
AND lower(type.code)='yacimiento'
|
||||||
|
AND type.is_active=true
|
||||||
|
AND yacimiento.information_status<>'INACTIVE'
|
||||||
|
LIMIT 1
|
||||||
|
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
|
||||||
|
|
||||||
|
if (!scope) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
|
||||||
|
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
|
|||||||
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
||||||
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||||
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||||
|
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
|
||||||
|
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
|
||||||
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
||||||
import { InspectionVisitsService } from './inspection-visits.service';
|
import { InspectionVisitsService } from './inspection-visits.service';
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ export class InspectionVisitsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly visits: InspectionVisitsService,
|
private readonly visits: InspectionVisitsService,
|
||||||
private readonly lifecycle: InspectionVisitLifecycleService,
|
private readonly lifecycle: InspectionVisitLifecycleService,
|
||||||
|
private readonly planningHierarchy: InspectionPlanningHierarchyService,
|
||||||
|
private readonly planningCreate: InspectionPlanningCreateService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -45,6 +49,29 @@ export class InspectionVisitsController {
|
|||||||
return this.visits.listAssignees();
|
return this.visits.listAssignees();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('planning-context/departments')
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
planningDepartments() {
|
||||||
|
return this.planningHierarchy.departments();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('planning-context/departments/:departmentId/areas')
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
planningAreasForDepartment(
|
||||||
|
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
|
||||||
|
) {
|
||||||
|
return this.planningHierarchy.areasForDepartment(departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('planning-context/areas/:areaId/yacimientos')
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
planningYacimientos(
|
||||||
|
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||||
|
) {
|
||||||
|
return this.planningHierarchy.yacimientosForArea(areaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
|
||||||
@Get('planning-context/areas')
|
@Get('planning-context/areas')
|
||||||
@RequirePermissions('inspections.read')
|
@RequirePermissions('inspections.read')
|
||||||
planningAreas() {
|
planningAreas() {
|
||||||
@@ -55,8 +82,9 @@ export class InspectionVisitsController {
|
|||||||
@RequirePermissions('inspections.read')
|
@RequirePermissions('inspections.read')
|
||||||
planningOperators(
|
planningOperators(
|
||||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||||
|
@Query('at') at?: string,
|
||||||
) {
|
) {
|
||||||
return this.visits.listPlanningOperators(areaId);
|
return this.planningHierarchy.operatorsForArea(areaId, at);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('mobile/planning-context/areas')
|
@Get('mobile/planning-context/areas')
|
||||||
@@ -84,8 +112,8 @@ export class InspectionVisitsController {
|
|||||||
@Req() request: RequestWithContext,
|
@Req() request: RequestWithContext,
|
||||||
) {
|
) {
|
||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
// Android no tiene un lifecycle paralelo: crea la visita y delega las
|
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
|
||||||
// transiciones DRAFT → PLANNED → IN_PROGRESS a los servicios canónicos.
|
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
|
||||||
const created = await this.visits.create({
|
const created = await this.visits.create({
|
||||||
operationalAreaId: dto.operationalAreaId,
|
operationalAreaId: dto.operationalAreaId,
|
||||||
operatorCompanyId: dto.operatorCompanyId,
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
@@ -109,7 +137,7 @@ export class InspectionVisitsController {
|
|||||||
@CurrentAuth() principal: AuthPrincipal,
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
@Req() request: RequestWithContext,
|
@Req() request: RequestWithContext,
|
||||||
) {
|
) {
|
||||||
return this.visits.create(dto, principal, request);
|
return this.planningCreate.create(dto, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { FieldFindingsController } from './field-findings.controller';
|
|||||||
import { FieldFindingsService } from './field-findings.service';
|
import { FieldFindingsService } from './field-findings.service';
|
||||||
import { FieldInventoryController } from './field-inventory.controller';
|
import { FieldInventoryController } from './field-inventory.controller';
|
||||||
import { FieldInventoryService } from './field-inventory.service';
|
import { FieldInventoryService } from './field-inventory.service';
|
||||||
|
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
|
||||||
|
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
|
||||||
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
||||||
import { InspectionVisitsController } from './inspection-visits.controller';
|
import { InspectionVisitsController } from './inspection-visits.controller';
|
||||||
import { InspectionVisitsService } from './inspection-visits.service';
|
import { InspectionVisitsService } from './inspection-visits.service';
|
||||||
@@ -17,6 +19,8 @@ import { InspectionVisitsService } from './inspection-visits.service';
|
|||||||
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
|
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
|
||||||
|
InspectionPlanningCreateService,
|
||||||
|
InspectionPlanningHierarchyService,
|
||||||
InspectionVisitLifecycleService,
|
InspectionVisitLifecycleService,
|
||||||
FieldInventoryService,
|
FieldInventoryService,
|
||||||
F3FieldInventoryStructureService,
|
F3FieldInventoryStructureService,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
|
|||||||
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||||
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||||
import { nextInspectionVisitCode } from './inspection-visit-code';
|
import { nextInspectionVisitCode } from './inspection-visit-code';
|
||||||
|
import { validateInspectionPlanningScope } from './inspection-planning-scope';
|
||||||
|
|
||||||
export interface InspectionPerson {
|
export interface InspectionPerson {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -312,7 +313,7 @@ export class InspectionVisitsService {
|
|||||||
}
|
}
|
||||||
const operationalAreaId = dto.operationalAreaId;
|
const operationalAreaId = dto.operationalAreaId;
|
||||||
const operatorCompanyId = dto.operatorCompanyId;
|
const operatorCompanyId = dto.operatorCompanyId;
|
||||||
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
|
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
|
||||||
await this.requireAsset(manager, operationalAreaId);
|
await this.requireAsset(manager, operationalAreaId);
|
||||||
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
|
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
|
||||||
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||||
@@ -382,29 +383,24 @@ export class InspectionVisitsService {
|
|||||||
? visit.operatorCompanyId
|
? visit.operatorCompanyId
|
||||||
: dto.operatorCompanyId;
|
: dto.operatorCompanyId;
|
||||||
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
|
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
|
||||||
const nextScope = contextTouched
|
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
|
||||||
? nextAreaId
|
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
|
||||||
: dto.scopeAssetId === undefined
|
|
||||||
? visit.scopeAssetId
|
|
||||||
: dto.scopeAssetId;
|
|
||||||
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
|
|
||||||
await this.requireAsset(manager, nextScope);
|
await this.requireAsset(manager, nextScope);
|
||||||
if (contextTouched && nextAreaId && nextCompanyId) {
|
if (nextAreaId && nextScope) {
|
||||||
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
|
await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
|
||||||
} else if (dto.scopeAssetId !== undefined) {
|
|
||||||
await this.assertCurrentAssetsInScope(manager, id, nextScope);
|
await this.assertCurrentAssetsInScope(manager, id, nextScope);
|
||||||
}
|
}
|
||||||
if (dto.objective !== undefined) visit.objective = dto.objective;
|
if (dto.objective !== undefined) visit.objective = dto.objective;
|
||||||
if (contextTouched) {
|
if (contextTouched) {
|
||||||
visit.operationalAreaId = nextAreaId;
|
visit.operationalAreaId = nextAreaId;
|
||||||
visit.operatorCompanyId = nextCompanyId;
|
visit.operatorCompanyId = nextCompanyId;
|
||||||
visit.scopeAssetId = nextAreaId;
|
}
|
||||||
} else if (dto.scopeAssetId !== undefined) {
|
if (dto.scopeAssetId !== undefined) {
|
||||||
visit.scopeAssetId = dto.scopeAssetId;
|
visit.scopeAssetId = dto.scopeAssetId;
|
||||||
}
|
}
|
||||||
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
|
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
|
||||||
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
|
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;
|
visit.updatedBy = principal.userId;
|
||||||
await manager.getRepository(InspectionVisit).save(visit);
|
await manager.getRepository(InspectionVisit).save(visit);
|
||||||
const updated = await this.loadView(manager, id);
|
const updated = await this.loadView(manager, id);
|
||||||
@@ -463,11 +459,10 @@ export class InspectionVisitsService {
|
|||||||
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
|
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.assertAssetsMatchContext(
|
await this.assertAssetsInScope(
|
||||||
manager,
|
manager,
|
||||||
additions,
|
additions,
|
||||||
visit.operationalAreaId,
|
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||||
visit.operatorCompanyId,
|
|
||||||
);
|
);
|
||||||
for (const assetId of additions) {
|
for (const assetId of additions) {
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
@@ -629,11 +624,10 @@ export class InspectionVisitsService {
|
|||||||
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
|
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
|
||||||
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
|
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
|
||||||
}
|
}
|
||||||
await this.assertAssetsMatchContext(
|
await this.assertAssetsInScope(
|
||||||
manager,
|
manager,
|
||||||
[assetId],
|
[assetId],
|
||||||
visit.operationalAreaId,
|
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||||
visit.operatorCompanyId,
|
|
||||||
);
|
);
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
UPDATE inspection_visit_assets
|
UPDATE inspection_visit_assets
|
||||||
@@ -920,6 +914,7 @@ export class InspectionVisitsService {
|
|||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
operationalAreaId: string | null,
|
operationalAreaId: string | null,
|
||||||
operatorCompanyId: string | null,
|
operatorCompanyId: string | null,
|
||||||
|
effectiveAt: Date | null = null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!operationalAreaId && !operatorCompanyId) return;
|
if (!operationalAreaId && !operatorCompanyId) return;
|
||||||
if (!operationalAreaId || !operatorCompanyId) {
|
if (!operationalAreaId || !operatorCompanyId) {
|
||||||
@@ -938,8 +933,8 @@ export class InspectionVisitsService {
|
|||||||
WHERE relation.area_id = area.id
|
WHERE relation.area_id = area.id
|
||||||
AND relation.company_id = company.id
|
AND relation.company_id = company.id
|
||||||
AND relation.relation_role = 'OPERATOR'
|
AND relation.relation_role = 'OPERATOR'
|
||||||
AND relation.valid_from <= CURRENT_TIMESTAMP
|
AND relation.valid_from <= $3::timestamptz
|
||||||
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
|
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
|
||||||
) AS "activeOperatorRelation"
|
) AS "activeOperatorRelation"
|
||||||
FROM assets area
|
FROM assets area
|
||||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
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 company_type.operational_role = 'COMPANY'
|
||||||
AND area.information_status <> 'INACTIVE'
|
AND area.information_status <> 'INACTIVE'
|
||||||
AND company.information_status <> 'INACTIVE'
|
AND company.information_status <> 'INACTIVE'
|
||||||
`, [operationalAreaId, operatorCompanyId])) as Array<{
|
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
|
||||||
areaId: string;
|
areaId: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
activeOperatorRelation: boolean;
|
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(
|
private async appendAssetEvent(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
visitId: string,
|
visitId: string,
|
||||||
@@ -1041,7 +998,7 @@ export class InspectionVisitsService {
|
|||||||
message: 'Indicá la fecha prevista antes de generar el checklist',
|
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;
|
const generation = visit.checklistGeneration + 1;
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_visit_checklist_items (
|
INSERT INTO inspection_visit_checklist_items (
|
||||||
@@ -1106,14 +1063,25 @@ export class InspectionVisitsService {
|
|||||||
FROM inspection_findings finding
|
FROM inspection_findings finding
|
||||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||||
WHERE asset.operational_area_id = $3
|
WHERE asset.operational_area_id = $3
|
||||||
AND asset.operator_company_id = $4
|
|
||||||
AND finding.status <> 'VOIDED'
|
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
|
ORDER BY finding.created_at, finding.id
|
||||||
`, [
|
`, [
|
||||||
visit.id,
|
visit.id,
|
||||||
generation,
|
generation,
|
||||||
visit.operationalAreaId,
|
visit.operationalAreaId,
|
||||||
visit.operatorCompanyId,
|
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||||
visit.plannedStartAt.toISOString().slice(0, 10),
|
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',
|
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) {
|
if (!visit.scopeAssetId) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'INSPECTION_SCOPE_REQUIRED',
|
code: 'INSPECTION_SCOPE_REQUIRED',
|
||||||
@@ -1350,13 +1323,10 @@ export class InspectionVisitsService {
|
|||||||
message: 'El equipo debe incluir al inspector responsable',
|
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.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[]> {
|
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
|||||||
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
|
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||||
|
|
||||||
assert.match(gradle, /versionCode = 23/);
|
assert.match(gradle, /versionCode = 24/);
|
||||||
assert.match(gradle, /versionName = "0\.15\.1"/);
|
assert.match(gradle, /versionName = "0\.15\.2"/);
|
||||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||||
});
|
});
|
||||||
@@ -25,4 +25,4 @@ test('F5/F6.1 field inventory exposes Other families as reviewable choices to An
|
|||||||
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
||||||
assert.match(service, /AS "isOther"/);
|
assert.match(service, /AS "isOther"/);
|
||||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||||
});
|
});
|
||||||
@@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
|
test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
|
||||||
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
|
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
|
||||||
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
|
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
|
||||||
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
|
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
|
||||||
@@ -129,7 +129,8 @@ test('F5 source contracts keep Empresa out of physical ownership and preserve se
|
|||||||
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
|
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
|
||||||
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
|
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
|
||||||
|
|
||||||
assert.match(fieldSource, /Área de esta inspección/);
|
assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/);
|
||||||
|
assert.match(fieldSource, /scopeAssetId/);
|
||||||
assert.match(fieldSource, /area_company_relations/);
|
assert.match(fieldSource, /area_company_relations/);
|
||||||
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
|
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F6.1 inspection planning exposes Departamento → Área → Yacimiento explicitly', () => {
|
||||||
|
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||||
|
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||||
|
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
|
||||||
|
|
||||||
|
assert.match(hierarchy, /lower\(type\.code\)='departamento'/);
|
||||||
|
assert.match(hierarchy, /area\.parent_id=\$1::uuid/);
|
||||||
|
assert.match(hierarchy, /lower\(type\.code\)='yacimiento'/);
|
||||||
|
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
|
||||||
|
assert.match(controller, /planning-context\/departments/);
|
||||||
|
assert.match(controller, /planning-context\/departments\/:departmentId\/areas/);
|
||||||
|
assert.match(controller, /planning-context\/areas\/:areaId\/yacimientos/);
|
||||||
|
assert.match(createPage, /Departamento → Área → Yacimiento/);
|
||||||
|
assert.match(createPage, /<span>Departamento<\/span>/);
|
||||||
|
assert.match(createPage, /<span>Área<\/span>/);
|
||||||
|
assert.match(createPage, /<span>Yacimiento<\/span>/);
|
||||||
|
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
|
||||||
|
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
|
||||||
|
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
|
||||||
|
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
|
||||||
|
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
|
||||||
|
|
||||||
|
assert.match(dto, /scopeAssetId\?: string/);
|
||||||
|
assert.match(scope, /yacimiento\.parent_id=\$2::uuid/);
|
||||||
|
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
|
||||||
|
assert.match(create, /scopeAssetId,/);
|
||||||
|
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
|
||||||
|
assert.match(create, /relation\.relation_role='OPERATOR'/);
|
||||||
|
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
|
||||||
|
assert.match(createPage, /scopeAssetId: yacimientoId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
|
||||||
|
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
|
||||||
|
|
||||||
|
assert.match(create, /area_company_relations relation/);
|
||||||
|
assert.doesNotMatch(create, /inventory\.operator_company_id/);
|
||||||
|
assert.doesNotMatch(create, /asset\.operator_company_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F4 checklist classification is derived on read and never rewrites the persisted generation', () => {
|
||||||
|
const f4 = source('src/inspection-visits/f4-inspection-visits.service.ts');
|
||||||
|
const d55 = source('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
|
||||||
|
|
||||||
|
assert.match(d55, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items/);
|
||||||
|
assert.match(f4, /classifyChecklistView/);
|
||||||
|
assert.match(f4, /FROM inspection_findings finding/);
|
||||||
|
assert.doesNotMatch(f4, /UPDATE inspection_visit_checklist_items/);
|
||||||
|
assert.doesNotMatch(f4, /reclassifyCurrentChecklist/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Field preparation is an Inspection stage, not a parallel sidebar module', () => {
|
||||||
|
const layout = source('../web-v2/src/layout/AppLayout.tsx');
|
||||||
|
const briefing = source('../web-v2/src/pages/FieldBriefingsPage.tsx');
|
||||||
|
const detail = source('../web-v2/src/pages/InspectionVisitDetailF61Page.tsx');
|
||||||
|
const app = source('../web-v2/src/app/App.tsx');
|
||||||
|
|
||||||
|
assert.doesNotMatch(layout, /label: 'Preparación de campo'/);
|
||||||
|
assert.match(briefing, /ETAPA DE LA INSPECCIÓN/);
|
||||||
|
assert.match(briefing, /inspectionId/);
|
||||||
|
assert.match(briefing, /Volver a la inspección/);
|
||||||
|
assert.match(detail, /ETAPA PREVIA AL CAMPO/);
|
||||||
|
assert.match(detail, /preparacion-campo\?inspectionId=/);
|
||||||
|
assert.match(app, /InspectionVisitDetailF61Page/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.1 field Inventory is bounded by the frozen Yacimiento scope', () => {
|
||||||
|
const source = api('src/inspection-visits/field-inventory.service.ts');
|
||||||
|
|
||||||
|
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
|
||||||
|
assert.match(source, /const effectiveParentId = parentId \?\? context\.scopeAssetId/);
|
||||||
|
assert.match(source, /const parentId = dto\.parentId \?\? context\.scopeAssetId/);
|
||||||
|
assert.match(source, /\[parentId, context\.scopeAssetId\]/);
|
||||||
|
assert.match(source, /\[assetId, context\.scopeAssetId\]/);
|
||||||
|
assert.match(source, /AS "insideScope"/);
|
||||||
|
assert.doesNotMatch(source, /insideArea/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope', () => {
|
||||||
|
const source = api('src/inspection-visits/field-findings.service.ts');
|
||||||
|
|
||||||
|
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
|
||||||
|
assert.match(source, /AS "insideScope"/);
|
||||||
|
assert.match(source, /if \(!row\.insideScope\)/);
|
||||||
|
assert.match(source, /Yacimiento definido como alcance/);
|
||||||
|
assert.doesNotMatch(source, /AS "insideArea"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 planning preserves Yacimiento and never validates Inventory ownership from Company snapshot', () => {
|
||||||
|
const source = api('src/inspection-visits/inspection-visits.service.ts');
|
||||||
|
|
||||||
|
assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/);
|
||||||
|
assert.match(source, /validateInspectionPlanningScope\(manager, nextAreaId, nextScope\)/);
|
||||||
|
assert.match(source, /visit\.scopeAssetId \?\? visit\.operationalAreaId/);
|
||||||
|
assert.match(source, /visit\.plannedStartAt,/);
|
||||||
|
assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/);
|
||||||
|
assert.doesNotMatch(source, /assertAssetsMatchContext/);
|
||||||
|
assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/);
|
||||||
|
assert.doesNotMatch(source, /asset\.operator_company_id IS DISTINCT FROM/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 creation selects actionable checklist Inventory automatically', () => {
|
||||||
|
const source = api('src/inspection-visits/inspection-planning-create.service.ts');
|
||||||
|
|
||||||
|
assert.match(source, /INSERT INTO inspection_visit_assets/);
|
||||||
|
assert.match(source, /COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL/);
|
||||||
|
assert.match(source, /'AUTO_INCLUDED'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 operator choices are resolved at the planned timestamp', () => {
|
||||||
|
const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||||
|
const controller = api('src/inspection-visits/inspection-visits.controller.ts');
|
||||||
|
const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx');
|
||||||
|
|
||||||
|
assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/);
|
||||||
|
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
|
||||||
|
assert.match(hierarchy, /relation\.valid_until > \$2::timestamptz/);
|
||||||
|
assert.match(controller, /@Query\('at'\) at\?: string/);
|
||||||
|
assert.match(createPage, /operators\?at=\$\{at\}/);
|
||||||
|
assert.match(createPage, /\[areaId, plannedStartAt\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => {
|
||||||
|
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
|
||||||
|
|
||||||
|
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
|
||||||
|
assert.match(source, /<span>Área<\/span>/);
|
||||||
|
assert.match(source, /<span>Yacimiento<\/span>/);
|
||||||
|
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F6.1 planning preloads the local start time and resolves the Area operator immediately', () => {
|
||||||
|
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
|
||||||
|
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
|
||||||
|
|
||||||
|
assert.match(page, /function currentLocalDateTime\(\)/);
|
||||||
|
assert.match(page, /useState\(currentLocalDateTime\)/);
|
||||||
|
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
|
||||||
|
assert.match(page, /response\.data\.length === 1/);
|
||||||
|
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
|
||||||
|
assert.match(page, /Sin Operadora vigente para esa fecha/);
|
||||||
|
assert.match(hierarchy, /relation\.relation_role='OPERATOR'/);
|
||||||
|
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
|
||||||
|
assert.match(hierarchy, /relation\.valid_until IS NULL OR relation\.valid_until > \$2::timestamptz/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
|
||||||
|
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
|
||||||
|
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
|
||||||
|
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
|
||||||
|
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
|
||||||
|
|
||||||
|
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
|
||||||
|
assert.match(editor, /showCancelForm/);
|
||||||
|
assert.match(editor, /cancelReason/);
|
||||||
|
assert.match(editor, /<textarea/);
|
||||||
|
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
|
||||||
|
assert.match(editor, /maxLength=\{500\}/);
|
||||||
|
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
|
||||||
|
assert.match(editor, /Confirmar cancelación/);
|
||||||
|
assert.match(controller, /@Post\(':id\/cancel'\)/);
|
||||||
|
assert.match(dto, /@MinLength\(10\)/);
|
||||||
|
assert.match(dto, /@MaxLength\(4000\)/);
|
||||||
|
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
|
||||||
|
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
|
||||||
|
});
|
||||||
@@ -59,11 +59,12 @@ test('F6 field findings resolve by exact technical classification and keep OTROS
|
|||||||
assert.match(resolver, /label: 'OTROS'/);
|
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', () => {
|
test('F6.1 mobile finding gate uses Yacimiento scope and temporal Area operator relation instead of Inventory company ownership', () => {
|
||||||
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
|
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
|
||||||
|
|
||||||
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
|
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
|
||||||
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
|
assert.match(fieldFindings, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\)/);
|
||||||
|
assert.match(fieldFindings, /AS "insideScope"/);
|
||||||
assert.match(fieldFindings, /FROM area_company_relations relation/);
|
assert.match(fieldFindings, /FROM area_company_relations relation/);
|
||||||
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
|
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
|
||||||
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
|
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
|
||||||
@@ -79,12 +80,15 @@ test('F6 field-created technical Inventory may be provisional only until the sel
|
|||||||
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
|
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
|
test('F6.1 Android roots search and reload at the Inspection Yacimiento with legacy Area fallback', () => {
|
||||||
|
const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||||
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
|
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');
|
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
|
||||||
|
|
||||||
|
assert.match(model, /data class VisitDetail[\s\S]*val scopeAsset: AssetSummary\? = null/);
|
||||||
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
|
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
|
||||||
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
|
assert.match(viewModel, /currentVisit\.scopeAsset\?\.id \?: currentVisit\.operationalArea\?\.id/);
|
||||||
|
assert.match(viewModel, /visit\?\.scopeAsset\?\.id \?: visit\?\.operationalArea\?\.id/);
|
||||||
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
|
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
|
||||||
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
|
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
docker compose --env-file .env.example up -d db
|
||||||
|
|
||||||
|
bootstrap_log="$(mktemp)"
|
||||||
|
set +e
|
||||||
|
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
|
||||||
|
bootstrap_status=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
if [ "$bootstrap_status" -eq 0 ]; then
|
||||||
|
echo 'ERROR: expected historical one-shot reset guard on clean database.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
|
||||||
|
echo 'ERROR: migration chain failed before the expected historical reset guard.' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
rm -f "$bootstrap_log"
|
||||||
|
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
INSERT INTO typeorm_migrations ("timestamp", name)
|
||||||
|
SELECT 1788652800000, 'ResetProductionOperationalData1788652800000'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM typeorm_migrations
|
||||||
|
WHERE name='ResetProductionOperationalData1788652800000'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||||
|
|
||||||
|
# Prove the presentation preload is a physical tree, not a flattened list.
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE invalid_areas integer; invalid_yacimientos integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO invalid_areas
|
||||||
|
FROM assets area
|
||||||
|
JOIN asset_types type ON type.id=area.asset_type_id
|
||||||
|
LEFT JOIN assets department ON department.id=area.parent_id
|
||||||
|
LEFT JOIN asset_types department_type ON department_type.id=department.asset_type_id
|
||||||
|
WHERE lower(type.code)='area'
|
||||||
|
AND (department.id IS NULL OR lower(department_type.code)<>'departamento');
|
||||||
|
IF invalid_areas <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'Invalid Area→Departamento relations: %', invalid_areas;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO invalid_yacimientos
|
||||||
|
FROM assets yacimiento
|
||||||
|
JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||||
|
LEFT JOIN assets area ON area.id=yacimiento.parent_id
|
||||||
|
LEFT JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
AND (area.id IS NULL OR lower(area_type.code)<>'area'
|
||||||
|
OR yacimiento.operational_area_id IS DISTINCT FROM area.id);
|
||||||
|
IF invalid_yacimientos <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'Invalid Yacimiento→Area relations: %', invalid_yacimientos;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||||
|
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||||
|
export SMTP_SETTINGS_MASTER_KEY=''
|
||||||
|
docker compose --env-file .env.example build api
|
||||||
|
|
||||||
|
# Disposable CI identities. The real Inspector invariant requires an email.
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
INSERT INTO users(id,username,email,password_hash,first_name,last_name,status,must_change_password)
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-4111-8111-111111111111','ci_planner','ci-planner@example.invalid','ci-unused','CI','Planner','ACTIVE',false),
|
||||||
|
('22222222-2222-4222-8222-222222222222','ci_inspector','ci-inspector@example.invalid','ci-unused','CI','Inspector','ACTIVE',false);
|
||||||
|
|
||||||
|
INSERT INTO user_roles(user_id,role_id,assigned_by)
|
||||||
|
SELECT '11111111-1111-4111-8111-111111111111'::uuid, role.id,
|
||||||
|
'11111111-1111-4111-8111-111111111111'::uuid
|
||||||
|
FROM roles role
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||||
|
WHERE rp.role_id=role.id AND p.code='inspections.manage'
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||||
|
WHERE rp.role_id=role.id AND p.code='inspections.assign'
|
||||||
|
)
|
||||||
|
ORDER BY role.is_system DESC,role.code
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
INSERT INTO user_roles(user_id,role_id,assigned_by)
|
||||||
|
SELECT '22222222-2222-4222-8222-222222222222'::uuid, role.id,
|
||||||
|
'11111111-1111-4111-8111-111111111111'::uuid
|
||||||
|
FROM roles role
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||||
|
WHERE rp.role_id=role.id AND p.code='inspections.execute'
|
||||||
|
)
|
||||||
|
ORDER BY (lower(role.code)='inspector') DESC,role.is_system DESC,role.code
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='11111111-1111-4111-8111-111111111111'::uuid) THEN
|
||||||
|
RAISE EXCEPTION 'CI planner role with manage+assign permissions not found';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='22222222-2222-4222-8222-222222222222'::uuid) THEN
|
||||||
|
RAISE EXCEPTION 'CI inspector role with execute permission not found';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
INSERT INTO auth_sessions(
|
||||||
|
id,user_id,refresh_token_hash,expires_at,last_used_at,revoked_at,replaced_by_session_id,ip,user_agent,device_label
|
||||||
|
) VALUES (
|
||||||
|
'33333333-3333-4333-8333-333333333333',
|
||||||
|
'11111111-1111-4111-8111-111111111111','ci-unused',
|
||||||
|
CURRENT_TIMESTAMP + INTERVAL '10 minutes',CURRENT_TIMESTAMP,NULL,NULL,NULL,'CI','inspection-planning-smoke'
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
|
||||||
|
docker compose --env-file .env.example up -d api
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null
|
||||||
|
|
||||||
|
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
process.stdout.write(jwt.sign({
|
||||||
|
sub:'11111111-1111-4111-8111-111111111111',
|
||||||
|
sid:'33333333-3333-4333-8333-333333333333',
|
||||||
|
username:'ci_planner',typ:'access'
|
||||||
|
}, process.env.JWT_ACCESS_SECRET, {
|
||||||
|
algorithm:'HS256',expiresIn:300,issuer:'dhv2-api',audience:'dhv2'
|
||||||
|
}));
|
||||||
|
NODE
|
||||||
|
)"
|
||||||
|
|
||||||
|
CONTEXT="$(docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -At -F '|' -U dhv2_owner -d dhv2 -c "
|
||||||
|
SELECT department.id,area.id,yacimiento.id,relation.company_id
|
||||||
|
FROM assets department
|
||||||
|
JOIN asset_types department_type ON department_type.id=department.asset_type_id
|
||||||
|
JOIN assets area ON area.parent_id=department.id
|
||||||
|
JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||||
|
JOIN assets yacimiento ON yacimiento.parent_id=area.id
|
||||||
|
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||||
|
JOIN area_company_relations relation ON relation.area_id=area.id
|
||||||
|
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
|
||||||
|
WHERE lower(department_type.code)='departamento'
|
||||||
|
AND lower(area_type.code)='area'
|
||||||
|
AND lower(yacimiento_type.code)='yacimiento'
|
||||||
|
ORDER BY department.name,area.name,yacimiento.name
|
||||||
|
LIMIT 1;
|
||||||
|
")"
|
||||||
|
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
|
||||||
|
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
|
||||||
|
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" \
|
||||||
|
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \
|
||||||
|
>/tmp/dhv2-departments.json
|
||||||
|
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
|
||||||
|
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
|
||||||
|
>/tmp/dhv2-areas.json
|
||||||
|
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
|
||||||
|
|
||||||
|
curl -fsS -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
|
||||||
|
>/tmp/dhv2-yacimientos.json
|
||||||
|
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
|
||||||
|
|
||||||
|
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
|
||||||
|
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \
|
||||||
|
http://127.0.0.1:3101/api/v3/inspection-visits)"
|
||||||
|
if [ "$HTTP_CODE" != 201 ]; then
|
||||||
|
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
|
||||||
|
cat /tmp/dhv2-created.json >&2
|
||||||
|
docker compose --env-file .env.example logs --no-color api >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VISIT_ID="$(python - <<'PY'
|
||||||
|
import json
|
||||||
|
with open('/tmp/dhv2-created.json',encoding='utf-8') as f:
|
||||||
|
print(json.load(f)['id'])
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
|
||||||
|
VERIFY="$(docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -At -U dhv2_owner -d dhv2 -c "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM inspection_visits visit
|
||||||
|
JOIN assets yacimiento ON yacimiento.id=visit.scope_asset_id
|
||||||
|
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||||
|
WHERE visit.id='$VISIT_ID'::uuid
|
||||||
|
AND visit.operational_area_id='$AREA_ID'::uuid
|
||||||
|
AND visit.scope_asset_id='$YACIMIENTO_ID'::uuid
|
||||||
|
AND visit.operator_company_id='$COMPANY_ID'::uuid
|
||||||
|
AND yacimiento.parent_id=visit.operational_area_id
|
||||||
|
AND lower(yacimiento_type.code)='yacimiento';
|
||||||
|
")"
|
||||||
|
if [ "$VERIFY" != '1' ]; then
|
||||||
|
echo 'ERROR: created Inspection did not preserve Area/Yacimiento hierarchy.' >&2
|
||||||
|
cat /tmp/dhv2-created.json >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "F6.1 real inspection create smoke: OK ($VISIT_ID)"
|
||||||
@@ -21,7 +21,8 @@ import { AssetTypesPage } from '../pages/AssetTypesPage';
|
|||||||
import { HistoryPage } from '../pages/HistoryPage';
|
import { HistoryPage } from '../pages/HistoryPage';
|
||||||
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
|
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
|
||||||
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
|
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
|
||||||
import { InspectionVisitEditorF4Page } from '../pages/InspectionVisitEditorF4Page';
|
import { InspectionVisitCreateF61Page } from '../pages/InspectionVisitCreateF61Page';
|
||||||
|
import { InspectionVisitDetailF61Page } from '../pages/InspectionVisitDetailF61Page';
|
||||||
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
|
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
|
||||||
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
|
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
|
||||||
import { FindingDetailPage } from '../pages/FindingDetailPage';
|
import { FindingDetailPage } from '../pages/FindingDetailPage';
|
||||||
@@ -57,10 +58,10 @@ export function App() {
|
|||||||
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
|
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
|
||||||
<Route element={<PermissionRoute permission="inspections.read" />}>
|
<Route element={<PermissionRoute permission="inspections.read" />}>
|
||||||
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
|
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
|
||||||
<Route path="/inspecciones/:id" element={<InspectionVisitEditorF4Page />} />
|
<Route path="/inspecciones/:id" element={<InspectionVisitDetailF61Page />} />
|
||||||
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
|
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorF4Page />} /></Route>
|
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitCreateF61Page />} /></Route>
|
||||||
<Route element={<PermissionRoute permission="inspection_acts.read" />}>
|
<Route element={<PermissionRoute permission="inspection_acts.read" />}>
|
||||||
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
|
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
|
||||||
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
|
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface NavItem {
|
|||||||
const operational: NavItem[] = [
|
const operational: NavItem[] = [
|
||||||
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
|
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
|
||||||
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
|
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
|
||||||
{ to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const followUp: NavItem[] = [
|
const followUp: NavItem[] = [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
|
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
|
||||||
import { formatDate, formatDateOnly } from '../lib/format';
|
import { formatDate, formatDateOnly } from '../lib/format';
|
||||||
@@ -17,6 +17,7 @@ interface VisitOption {
|
|||||||
code: string;
|
code: string;
|
||||||
status: string;
|
status: string;
|
||||||
plannedStartAt: string | null;
|
plannedStartAt: string | null;
|
||||||
|
scopeAsset?: { name: string; typeName?: string } | null;
|
||||||
operationalArea: { name: string } | null;
|
operationalArea: { name: string } | null;
|
||||||
operatorCompany: { name: string } | null;
|
operatorCompany: { name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -116,8 +117,10 @@ function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FieldBriefingsPage() {
|
export function FieldBriefingsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedInspectionId = searchParams.get('inspectionId') ?? '';
|
||||||
const [visits, setVisits] = useState<VisitOption[]>([]);
|
const [visits, setVisits] = useState<VisitOption[]>([]);
|
||||||
const [visitId, setVisitId] = useState('');
|
const [visitId, setVisitId] = useState(requestedInspectionId);
|
||||||
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
|
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
|
||||||
const [loadingVisits, setLoadingVisits] = useState(true);
|
const [loadingVisits, setLoadingVisits] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -132,12 +135,15 @@ export function FieldBriefingsPage() {
|
|||||||
const all = [...planned.data, ...drafts.data]
|
const all = [...planned.data, ...drafts.data]
|
||||||
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
|
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
|
||||||
setVisits(all);
|
setVisits(all);
|
||||||
const onlyVisit = all[0];
|
if (requestedInspectionId && all.some((visit) => visit.id === requestedInspectionId)) {
|
||||||
if (all.length === 1 && onlyVisit) setVisitId(onlyVisit.id);
|
setVisitId(requestedInspectionId);
|
||||||
|
} else if (all.length === 1 && all[0]) {
|
||||||
|
setVisitId(all[0].id);
|
||||||
|
}
|
||||||
}).catch((cause) => {
|
}).catch((cause) => {
|
||||||
setError(cause instanceof Error ? cause.message : String(cause));
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
}).finally(() => setLoadingVisits(false));
|
}).finally(() => setLoadingVisits(false));
|
||||||
}, []);
|
}, [requestedInspectionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visitId) {
|
if (!visitId) {
|
||||||
@@ -158,11 +164,12 @@ export function FieldBriefingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return <section className="field-briefing-page">
|
return <section className="field-briefing-page">
|
||||||
|
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Preparación para campo</span></div>
|
||||||
<div className="page-heading">
|
<div className="page-heading">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">ANTES DE SALIR A CAMPO</span>
|
<span className="eyebrow">ETAPA DE LA INSPECCIÓN</span>
|
||||||
<h1>Preparación de campo</h1>
|
<h1>Preparación para campo</h1>
|
||||||
<p>Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.</p>
|
<p>Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.</p>
|
||||||
</div>
|
</div>
|
||||||
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
|
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
|
||||||
Imprimir preparación
|
Imprimir preparación
|
||||||
@@ -170,15 +177,15 @@ export function FieldBriefingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-card no-print">
|
<div className="form-card no-print">
|
||||||
<label>Inspección planificada</label>
|
<label>Inspección</label>
|
||||||
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
|
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
|
||||||
value={visitId}
|
value={visitId}
|
||||||
onChange={(event) => setVisitId(event.target.value)}
|
onChange={(event) => setVisitId(event.target.value)}
|
||||||
searchPlaceholder="Buscar por código, Área u Operadora…"
|
searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…"
|
||||||
>
|
>
|
||||||
<option value="">Seleccionar inspección…</option>
|
<option value="">Seleccionar inspección…</option>
|
||||||
{visits.map((visit) => <option key={visit.id} value={visit.id}>
|
{visits.map((visit) => <option key={visit.id} value={visit.id}>
|
||||||
{visit.code} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
|
{visit.code} · {visit.scopeAsset?.name ?? 'Sin Yacimiento'} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
|
||||||
</option>)}
|
</option>)}
|
||||||
</SearchableSelect>}
|
</SearchableSelect>}
|
||||||
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
|
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
|
||||||
@@ -189,18 +196,18 @@ export function FieldBriefingsPage() {
|
|||||||
|
|
||||||
{!loading && !briefing && !error && <EmptyState
|
{!loading && !briefing && !error && <EmptyState
|
||||||
title="Seleccioná una inspección"
|
title="Seleccioná una inspección"
|
||||||
text="El sistema reunirá automáticamente los pendientes de inspecciones anteriores del mismo contexto."
|
text="El sistema reunirá automáticamente sus antecedentes y controles pendientes."
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
{!loading && briefing && <>
|
{!loading && briefing && <>
|
||||||
<div className="page-heading compact">
|
<div className="page-heading compact">
|
||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">{briefing.inspection.code}</span>
|
<span className="eyebrow">{briefing.inspection.code}</span>
|
||||||
<h2>{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}</h2>
|
<h2>{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}</h2>
|
||||||
<p>Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
|
<p>{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
|
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
|
||||||
Abrir planificación
|
Volver a la inspección
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { Alert, LoadingBlock } from '../components/Feedback';
|
||||||
|
import { Icon } from '../components/Icon';
|
||||||
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Inspector {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedInspection {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`/api/v3${url}`, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof payload?.message === 'string'
|
||||||
|
? payload.message
|
||||||
|
: typeof payload?.error?.message === 'string'
|
||||||
|
? payload.error.message
|
||||||
|
: `No se pudo completar la operación (${response.status}).`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectorName(person: Inspector): string {
|
||||||
|
return `${person.firstName} ${person.lastName}`.trim() || person.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLocalDateTime(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectionVisitCreateF61Page() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [departments, setDepartments] = useState<Option[]>([]);
|
||||||
|
const [areas, setAreas] = useState<Option[]>([]);
|
||||||
|
const [yacimientos, setYacimientos] = useState<Option[]>([]);
|
||||||
|
const [operators, setOperators] = useState<Option[]>([]);
|
||||||
|
const [inspectors, setInspectors] = useState<Inspector[]>([]);
|
||||||
|
const [departmentId, setDepartmentId] = useState('');
|
||||||
|
const [areaId, setAreaId] = useState('');
|
||||||
|
const [yacimientoId, setYacimientoId] = useState('');
|
||||||
|
const [operatorId, setOperatorId] = useState('');
|
||||||
|
const [inspectorId, setInspectorId] = useState('');
|
||||||
|
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingOperators, setLoadingOperators] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
requestJson<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
|
||||||
|
requestJson<{ data: Inspector[] }>('/inspection-visits/assignees'),
|
||||||
|
]).then(([departmentResponse, inspectorResponse]) => {
|
||||||
|
setDepartments(departmentResponse.data);
|
||||||
|
setInspectors(inspectorResponse.data);
|
||||||
|
}).catch((cause) => {
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
}).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAreaId('');
|
||||||
|
setYacimientoId('');
|
||||||
|
setOperatorId('');
|
||||||
|
setAreas([]);
|
||||||
|
setYacimientos([]);
|
||||||
|
setOperators([]);
|
||||||
|
if (!departmentId) return;
|
||||||
|
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
|
||||||
|
.then((response) => setAreas(response.data))
|
||||||
|
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
|
||||||
|
}, [departmentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setYacimientoId('');
|
||||||
|
setOperatorId('');
|
||||||
|
setYacimientos([]);
|
||||||
|
setOperators([]);
|
||||||
|
if (!areaId) return;
|
||||||
|
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
|
||||||
|
.then((response) => setYacimientos(response.data))
|
||||||
|
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
|
||||||
|
}, [areaId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOperatorId('');
|
||||||
|
setOperators([]);
|
||||||
|
if (!areaId || !plannedStartAt) return;
|
||||||
|
const parsedStart = new Date(plannedStartAt);
|
||||||
|
if (Number.isNaN(parsedStart.getTime())) return;
|
||||||
|
const at = encodeURIComponent(parsedStart.toISOString());
|
||||||
|
setLoadingOperators(true);
|
||||||
|
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
|
||||||
|
.then((response) => {
|
||||||
|
setOperators(response.data);
|
||||||
|
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
|
||||||
|
})
|
||||||
|
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
|
||||||
|
.finally(() => setLoadingOperators(false));
|
||||||
|
}, [areaId, plannedStartAt]);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId) {
|
||||||
|
setError('Completá Departamento, Área, Yacimiento, Operadora, fecha e Inspector.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsedStart = new Date(plannedStartAt);
|
||||||
|
if (Number.isNaN(parsedStart.getTime())) {
|
||||||
|
setError('La fecha y hora de inicio no es válida.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const created = await requestJson<CreatedInspection>('/inspection-visits', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
operationalAreaId: areaId,
|
||||||
|
scopeAssetId: yacimientoId,
|
||||||
|
operatorCompanyId: operatorId,
|
||||||
|
plannedStartAt: parsedStart.toISOString(),
|
||||||
|
leadInspectorUserId: inspectorId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
navigate(`/inspecciones/${created.id}`, { replace: true });
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : String(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <LoadingBlock label="Cargando estructura territorial…" />;
|
||||||
|
|
||||||
|
return <section className="survey-editor inspection-editor">
|
||||||
|
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Planificar inspección</span></div>
|
||||||
|
<div className="page-heading survey-editor-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">INSPECCIÓN</span>
|
||||||
|
<h1>Planificar inspección</h1>
|
||||||
|
<p>Seleccioná la ubicación respetando la jerarquía Departamento → Área → Yacimiento.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<form className="panel form-panel inspection-quick-create" onSubmit={submit}>
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">CREACIÓN RÁPIDA</span>
|
||||||
|
<h2>Ubicación, contexto y fecha</h2>
|
||||||
|
<p className="section-copy">La Operadora pertenece al contexto temporal del Área. El Yacimiento define el alcance físico de esta Inspección.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>Departamento</span>
|
||||||
|
<SearchableSelect value={departmentId} onChange={(event) => setDepartmentId(event.target.value)} searchPlaceholder="Buscar Departamento…" required>
|
||||||
|
<option value="">Seleccionar Departamento…</option>
|
||||||
|
{departments.map((department) => <option key={department.id} value={department.id}>{department.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Área</span>
|
||||||
|
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)} searchPlaceholder="Buscar Área…" required disabled={!departmentId}>
|
||||||
|
<option value="">Seleccionar Área…</option>
|
||||||
|
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Yacimiento</span>
|
||||||
|
<SearchableSelect value={yacimientoId} onChange={(event) => setYacimientoId(event.target.value)} searchPlaceholder="Buscar Yacimiento…" required disabled={!areaId}>
|
||||||
|
<option value="">Seleccionar Yacimiento…</option>
|
||||||
|
{yacimientos.map((yacimiento) => <option key={yacimiento.id} value={yacimiento.id}>{yacimiento.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Operadora del Área</span>
|
||||||
|
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || loadingOperators || operators.length === 0}>
|
||||||
|
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
|
||||||
|
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Fecha y hora de inicio</span>
|
||||||
|
<input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Inspector responsable</span>
|
||||||
|
<SearchableSelect value={inspectorId} onChange={(event) => setInspectorId(event.target.value)} searchPlaceholder="Buscar Inspector…" required>
|
||||||
|
<option value="">Seleccionar Inspector…</option>
|
||||||
|
{inspectors.map((inspector) => <option key={inspector.id} value={inspector.id}>{inspectorName(inspector)} · {inspector.username}</option>)}
|
||||||
|
</SearchableSelect>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
|
||||||
|
{areaId && plannedStartAt && !loadingOperators && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente para la fecha seleccionada. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<Link className="button secondary" to="/inspecciones">Volver</Link>
|
||||||
|
<button className="button primary" disabled={busy || !departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId}>
|
||||||
|
<Icon name="check" />{busy ? 'Creando…' : 'Crear inspección'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Link, useParams } from 'react-router';
|
||||||
|
import { Icon } from '../components/Icon';
|
||||||
|
import { InspectionVisitEditorF4Page } from './InspectionVisitEditorF4Page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F6.1 keeps Preparación para campo as a stage of the Inspection. The existing
|
||||||
|
* detail remains unchanged underneath; this wrapper makes the relationship
|
||||||
|
* explicit without creating a second operational module.
|
||||||
|
*/
|
||||||
|
export function InspectionVisitDetailF61Page() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{id && <article className="panel survey-status-actions no-print">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">ETAPA PREVIA AL CAMPO</span>
|
||||||
|
<strong>Preparación para campo</strong>
|
||||||
|
<p>Revisá antecedentes, hallazgos pendientes y próximos controles de esta Inspección antes de iniciarla desde la APK.</p>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<Link className="button secondary" to={`/preparacion-campo?inspectionId=${id}`}>
|
||||||
|
<Icon name="clipboard" /> Abrir preparación
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>}
|
||||||
|
<InspectionVisitEditorF4Page />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -109,6 +109,8 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
|
const [showCancelForm, setShowCancelForm] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState('');
|
||||||
|
|
||||||
const applyVisit = (value: InspectionVisit, syncForm = true) => {
|
const applyVisit = (value: InspectionVisit, syncForm = true) => {
|
||||||
setVisit(value);
|
setVisit(value);
|
||||||
@@ -207,6 +209,7 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
} else if (id) {
|
} else if (id) {
|
||||||
applyVisit(await updateInspectionVisit(id, {
|
applyVisit(await updateInspectionVisit(id, {
|
||||||
objective: form.objective.trim() || null,
|
objective: form.objective.trim() || null,
|
||||||
|
scopeAssetId: visit?.scopeAsset?.id ?? null,
|
||||||
operationalAreaId: form.operationalAreaId || null,
|
operationalAreaId: form.operationalAreaId || null,
|
||||||
operatorCompanyId: form.operatorCompanyId || null,
|
operatorCompanyId: form.operatorCompanyId || null,
|
||||||
plannedStartAt,
|
plannedStartAt,
|
||||||
@@ -286,7 +289,7 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
|
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setBusy(true); setError(''); setSuccess('');
|
setBusy(true); setError(''); setSuccess('');
|
||||||
try {
|
try {
|
||||||
@@ -294,23 +297,38 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
if (action === 'PLAN') {
|
if (action === 'PLAN') {
|
||||||
updated = await planInspectionVisit(id);
|
updated = await planInspectionVisit(id);
|
||||||
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
|
||||||
} else if (action === 'UNPLAN') {
|
} else {
|
||||||
updated = await unplanInspectionVisit(id);
|
updated = await unplanInspectionVisit(id);
|
||||||
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
|
setSuccess('La Inspección volvió a borrador para corregir la planificación.');
|
||||||
} else {
|
|
||||||
const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
|
|
||||||
if (reason.length < 10) {
|
|
||||||
if (reason) setError('El motivo debe tener al menos 10 caracteres.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updated = await cancelInspectionVisit(id, reason);
|
|
||||||
setSuccess('Inspección cancelada con motivo auditado.');
|
|
||||||
}
|
}
|
||||||
applyVisit(updated);
|
applyVisit(updated);
|
||||||
} catch (requestError) { setError(errorMessage(requestError)); }
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelCurrentVisit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!id) return;
|
||||||
|
const reason = cancelReason.trim();
|
||||||
|
if (reason.length < 10) {
|
||||||
|
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reason.length > 500) {
|
||||||
|
setError('El motivo de cancelación no puede superar los 500 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true); setError(''); setSuccess('');
|
||||||
|
try {
|
||||||
|
const updated = await cancelInspectionVisit(id, reason);
|
||||||
|
applyVisit(updated);
|
||||||
|
setCancelReason('');
|
||||||
|
setShowCancelForm(false);
|
||||||
|
setSuccess('Inspección cancelada. El motivo quedó registrado y auditado.');
|
||||||
|
} catch (requestError) { setError(errorMessage(requestError)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <LoadingBlock label="Cargando inspección…" />;
|
if (loading) return <LoadingBlock label="Cargando inspección…" />;
|
||||||
|
|
||||||
return <section className="survey-editor inspection-editor">
|
return <section className="survey-editor inspection-editor">
|
||||||
@@ -325,7 +343,8 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
|
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
|
||||||
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
|
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field"><span>Área / Yacimiento</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área…</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
|
<label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área…</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
|
||||||
|
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
|
||||||
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora…</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
|
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora…</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
|
||||||
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
|
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
|
||||||
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector…</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
|
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector…</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
|
||||||
@@ -351,7 +370,9 @@ export function InspectionVisitEditorF4Page() {
|
|||||||
|
|
||||||
{visit && <InspectionActsPanel visit={visit} />}
|
{visit && <InspectionActsPanel visit={visit} />}
|
||||||
|
|
||||||
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>}
|
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => { setError(''); setSuccess(''); setShowCancelForm(true); }}>Cancelar inspección</button>}</div></article>}
|
||||||
|
|
||||||
|
{visit && showCancelForm && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <form className="panel form-panel" onSubmit={cancelCurrentVisit}><div className="panel-heading"><div><span className="eyebrow">CANCELACIÓN</span><h2>Cancelar inspección</h2><p className="section-copy">La cancelación es definitiva y queda auditada. No se permite si la Inspección ya tiene un Acta sellada.</p></div></div><label className="field"><span>Motivo de cancelación</span><textarea value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} minLength={10} maxLength={500} rows={4} placeholder="Explicá el motivo de la cancelación…" required /><small className="muted">{cancelReason.trim().length}/500 · mínimo 10 caracteres</small></label><div className="form-actions"><button type="button" className="button secondary" disabled={busy} onClick={() => { setShowCancelForm(false); setCancelReason(''); }}>Volver</button><button type="submit" className="button danger-outline" disabled={busy || cancelReason.trim().length < 10 || cancelReason.trim().length > 500}>{busy ? 'Cancelando…' : 'Confirmar cancelación'}</button></div></form>}
|
||||||
|
|
||||||
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
|
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
|
||||||
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
|
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
|
||||||
|
|||||||
Reference in New Issue
Block a user