Compare commits

..
Author SHA1 Message Date
admin cedcbac678 test(F6.1): lock Android Yacimiento root contract 2026-09-10 15:55:52 -03:00
admin 610e011814 test(F6.1): supersede Area-wide field scope expectation 2026-09-10 15:55:34 -03:00
github-actions[bot] 20c0ef92e7 fix(android): root field inventory at inspection Yacimiento 2026-09-10 18:54:47 +00:00
admin d2b08c8687 chore(F6.1): apply Android Yacimiento root fix 2026-09-10 15:54:38 -03:00
admin 3a08972cc3 test(F6.1): lock Yacimiento scope review fixes 2026-09-10 15:50:12 -03:00
github-actions[bot] 736d09b849 fix(F6.1): validate planned context without snapshot ownership 2026-09-10 18:49:35 +00:00
admin ed4a3d5a44 chore(F6.1): finish planned scope consistency 2026-09-10 15:49:25 -03:00
github-actions[bot] b1f0064b40 fix(F6.1): enforce Yacimiento scope end to end 2026-09-10 18:45:42 +00:00
admin f99452c21c chore(F6.1): apply reviewed inspection scope fixes 2026-09-10 15:45:28 -03:00
admin 0df6ec19d6 fix(F6.1): resolve operators at planned inspection date 2026-09-10 15:43:05 -03:00
admin e4153ce3ec test(android): align F6.1 presentation version 0.15.2 2026-09-10 15:42:47 -03:00
admin dd0c9f461b feat(android): use exact Mendoza presentation launcher icon 2026-09-10 15:28:18 -03:00
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
3 changed files with 16 additions and 89 deletions
@@ -1,44 +0,0 @@
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/);
});
@@ -49,12 +49,6 @@ function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username; 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() { export function InspectionVisitCreateF61Page() {
const navigate = useNavigate(); const navigate = useNavigate();
const [departments, setDepartments] = useState<Option[]>([]); const [departments, setDepartments] = useState<Option[]>([]);
@@ -67,9 +61,8 @@ export function InspectionVisitCreateF61Page() {
const [yacimientoId, setYacimientoId] = useState(''); const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState(''); const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState(''); const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime); const [plannedStartAt, setPlannedStartAt] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingOperators, setLoadingOperators] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -117,14 +110,12 @@ export function InspectionVisitCreateF61Page() {
const parsedStart = new Date(plannedStartAt); const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) return; if (Number.isNaN(parsedStart.getTime())) return;
const at = encodeURIComponent(parsedStart.toISOString()); const at = encodeURIComponent(parsedStart.toISOString());
setLoadingOperators(true);
requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`) requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
.then((response) => { .then((response) => {
setOperators(response.data); setOperators(response.data);
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id); if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
}) })
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))) .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
.finally(() => setLoadingOperators(false));
}, [areaId, plannedStartAt]); }, [areaId, plannedStartAt]);
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
@@ -210,11 +201,10 @@ export function InspectionVisitCreateF61Page() {
<label className="field"> <label className="field">
<span>Operadora del Área</span> <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}> <SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || operators.length === 0}>
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option> <option value="">{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>)} {operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect> </SearchableSelect>
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
</label> </label>
<label className="field"> <label className="field">
@@ -232,7 +222,7 @@ export function InspectionVisitCreateF61Page() {
</div> </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 && 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>} {areaId && plannedStartAt && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions"> <div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link> <Link className="button secondary" to="/inspecciones">Volver</Link>
@@ -109,8 +109,6 @@ 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);
@@ -289,7 +287,7 @@ export function InspectionVisitEditorF4Page() {
finally { setBusy(false); } finally { setBusy(false); }
}; };
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => { const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => {
if (!id) return; if (!id) return;
setBusy(true); setError(''); setSuccess(''); setBusy(true); setError(''); setSuccess('');
try { try {
@@ -297,38 +295,23 @@ 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 { } else if (action === 'UNPLAN') {
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">
@@ -370,9 +353,7 @@ 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', '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 && <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 && 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>}