diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 18af583..0438477 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-9", + "version": "0.29.0-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-9", + "version": "0.29.0-10", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 0c80965..359297f 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-9", + "version": "0.29.0-10", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/inspection-closing/company-signature-invite.service.ts b/api-v3/src/inspection-closing/company-signature-invite.service.ts index 081243b..d834327 100644 --- a/api-v3/src/inspection-closing/company-signature-invite.service.ts +++ b/api-v3/src/inspection-closing/company-signature-invite.service.ts @@ -264,7 +264,6 @@ export class CompanySignatureInviteService { const invite = await this.resolveToken(this.dataSource.manager, token, false); const locked = invite.lockedSnapshot ?? {}; const act = this.record(locked.act); - const inventories = this.records(locked.inventories); const findings = this.records(locked.findings).map((finding) => ({ id: finding.id, code: finding.code, @@ -296,12 +295,6 @@ export class CompanySignatureInviteService { documentNumber: invite.recipientDocumentNumber, position: invite.recipientPosition, }, - inventories: inventories.map((inventory) => ({ - id: inventory.id, - code: inventory.code, - name: inventory.name, - typeName: inventory.typeName ?? inventory.typeCode ?? null, - })), findings, consent: REMOTE_COMPANY_CONSENT, allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'], diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index d698f86..c214314 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -929,6 +929,12 @@ export class InspectionClosingService { JOIN assets asset ON asset.id=link.asset_id JOIN asset_types type ON type.id=asset.asset_type_id WHERE link.act_id=$1 AND link.included=true + AND EXISTS ( + SELECT 1 FROM inspection_findings finding + WHERE finding.act_id=link.act_id + AND finding.asset_id=asset.id + AND finding.status<>'VOIDED' + ) ORDER BY asset.code,asset.id `, [actId]) as Array>; const findings = await manager.query(` diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index d0ef348..87d934a 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -38,7 +38,6 @@ export async function buildInspectionActPdf(snapshot: Record, i const act = asRecord(locked.act); const inspection = asRecord(act.inspection ?? act.visit); const responsible = asRecord(locked.responsible); - const inventories = asArray(locked.inventories ?? locked.assets); const findings = asArray(locked.findings); const signatures = asArray(sealed.signatures); const hash = text(sealed.finalSha256 ?? sealed.lockedSha256); @@ -87,32 +86,19 @@ export async function buildInspectionActPdf(snapshot: Record, i if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary); else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`); if (act.observations) { label('Observaciones', act.observations); } - if (inventories.length) { - heading('Instalaciones inspeccionadas'); - for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`); - } heading('Hallazgos y fotografías'); if (!findings.length) body('No se registraron hallazgos.'); - const shownAssetPhotos = new Set(); for (const finding of findings) { need(75); doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`); label('Descripción', finding.description); if (finding.legalBasis) label('Normativa consignada', finding.legalBasis); if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`); - for (const photo of images.filter((item) => item.findingId === text(finding.id))) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); - for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) { - if (shownAssetPhotos.has(photo.id)) continue; - shownAssetPhotos.add(photo.id); - image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); + for (const photo of images.filter((item) => item.findingId === text(finding.id))) { + image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); } doc.moveDown(0.4); } - const otherAssetPhotos = images.filter((item) => item.assetId && !shownAssetPhotos.has(item.id)); - if (otherAssetPhotos.length) { - heading('Otras instalaciones inspeccionadas'); - for (const photo of otherAssetPhotos) image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); - } heading('Intervinientes y firmas'); for (const signature of signatures) { const name = text(signature.signerName); diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index 2c2b718..ddbd159 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -66,7 +66,7 @@ export class InspectionActPdfService { `, [actId]); try { const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; - const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId)); + const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId)); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${actId}.pdf`; const originalName = `${row.code}.pdf`; @@ -113,7 +113,7 @@ export class InspectionActPdfService { message: 'El Acta debe estar firmada y sellada para generar el documento consolidado', }); const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; - const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId), await this.actContext(actId)); + const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId)); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`; const originalName = `${row.code}-consolidada.pdf`; @@ -176,7 +176,7 @@ export class InspectionActPdfService { return buffer; } - async fieldImages(actId: string): Promise { + async fieldImages(actId: string, includeAssetPhotos = true): Promise { type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number }; const findings = await this.dataSource.query(` SELECT evidence.id, finding.id AS "findingId", evidence.title, @@ -189,7 +189,7 @@ export class InspectionActPdfService { AND evidence.created_at<=act.locked_at ORDER BY finding.finding_number,evidence.captured_at,evidence.id `, [actId]) as ImageRow[]; - const assets = await this.dataSource.query(` + const assets = includeAssetPhotos ? await this.dataSource.query(` SELECT media.id,asset.id AS "assetId", asset.name AS title, capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName", media.sha256,media.size_bytes AS "sizeBytes" @@ -201,7 +201,7 @@ export class InspectionActPdfService { JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO' WHERE act.id=$1 AND capture.created_at<=act.locked_at ORDER BY capture.device_captured_at,media.id - `, [actId]) as ImageRow[]; + `, [actId]) as ImageRow[] : []; const signatures = await this.dataSource.query(` SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName", signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes" diff --git a/api-v3/test/unit/act-findings-only-regression.test.ts b/api-v3/test/unit/act-findings-only-regression.test.ts new file mode 100644 index 0000000..737a519 --- /dev/null +++ b/api-v3/test/unit/act-findings-only-regression.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8'); + +test('Acta PDF renders Hallazgos and finding evidence, never standalone Inventory or field photos', () => { + const source = api('inspection-reports/inspection-act-pdf-builder.ts'); + assert.match(source, /heading\('Hallazgos y fotografías'\)/); + assert.match(source, /item\.findingId === text\(finding\.id\)/); + assert.doesNotMatch(source, /Instalaciones inspeccionadas/); + assert.doesNotMatch(source, /Otras instalaciones inspeccionadas/); + assert.doesNotMatch(source, /item\.assetId === text\(finding\.assetId\)/); +}); + +test('Dashboard Acta shows only Hallazgos and evidence directly attached to them', () => { + const media = web('features/inspections/InspectionActMediaPanel.tsx'); + const editor = web('pages/InspectionActEditorPage.tsx'); + assert.match(media, /listInspectionFindingEvidence/); + assert.doesNotMatch(media, /listInspectionActFieldMedia/); + assert.doesNotMatch(media, /getAssetMediaBlob/); + assert.doesNotMatch(media, /Fotos de otras instalaciones/); + assert.doesNotMatch(editor, /Instalaciones inspeccionadas/); +}); + +test('Company signing view exposes Hallazgos as Acta content without a standalone Inventory list', () => { + const service = api('inspection-closing/company-signature-invite.service.ts'); + const viewBody = service.slice(service.indexOf(' async view(token: string)'), service.indexOf(' async sign(')); + const page = web('pages/CompanySignaturePage.tsx'); + assert.match(viewBody, /findings/); + assert.doesNotMatch(viewBody, /inventories/); + assert.match(page, />Hallazgos { + const closing = api('inspection-closing/inspection-closing.service.ts'); + const pdfService = api('inspection-reports/inspection-act-pdf.service.ts'); + const snapshotBody = closing.slice(closing.indexOf(' private async buildLockedSnapshot('), closing.indexOf(' private async deadlinePolicy(')); + assert.match(snapshotBody, /EXISTS \(\s*SELECT 1 FROM inspection_findings finding/); + assert.match(snapshotBody, /finding\.asset_id=asset\.id/); + assert.match(snapshotBody, /finding\.status<>'VOIDED'/); + assert.match(pdfService, /fieldImages\(actId, false\)/); + assert.match(pdfService, /includeAssetPhotos \? await this\.dataSource\.query/); +}); diff --git a/api-v3/test/unit/f6-9-consolidated-documents.test.ts b/api-v3/test/unit/f6-9-consolidated-documents.test.ts index 0a8b25c..46225a2 100644 --- a/api-v3/test/unit/f6-9-consolidated-documents.test.ts +++ b/api-v3/test/unit/f6-9-consolidated-documents.test.ts @@ -94,12 +94,12 @@ test('F6.9 revision migration archives both first document versions before servi assert.match(migration, /PRIMARY KEY \(report_id,template_version\)/); }); -test('F6.9 includes field photos from installations without their own findings', async () => { +test('Acta ignores standalone field photos while the technical Informe remains independent', async () => { const other = await sharp(photo).resize(75).png().toBuffer(); const unlinked = { id: 'photo-other-asset', assetId: 'asset-2', title: 'Otra instalación', sha256: digest(other), buffer: other }; const withUnlinked = await buildInspectionActPdf(sealed, [evidence, unlinked]); const linkedOnly = await buildInspectionActPdf(sealed, [evidence]); - assert.ok(withUnlinked.buffer.length > linkedOnly.buffer.length + 500); + assert.ok(Math.abs(withUnlinked.buffer.length - linkedOnly.buffer.length) < 500); const word = buildInspectionReportWord({ code: 'INF-OTHER', title: 'Informe', generatedAt: new Date(), frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] }); assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS'))); diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index 1a6e902..1b5c3a5 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-6", + "version": "0.23.0-7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-6", + "version": "0.23.0-7", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index e874141..8b2926a 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-6", + "version": "0.23.0-7", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/components/DocumentPdfProjection.tsx b/web-v2/src/components/DocumentPdfProjection.tsx index ecdd2e3..39bfc0c 100644 --- a/web-v2/src/components/DocumentPdfProjection.tsx +++ b/web-v2/src/components/DocumentPdfProjection.tsx @@ -18,7 +18,7 @@ const actSections: ProjectionSection[] = [ }, { title: 'Alcance y constatación', - description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.', + description: 'Objeto de la actuación, descripción de lo actuado y observaciones generales de la inspección.', }, { title: 'Hallazgos', @@ -34,7 +34,7 @@ const actSections: ProjectionSection[] = [ }, { title: 'Anexos', - description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.', + description: 'Sólo evidencia vinculada a Hallazgos del Acta. Las fotos de inventario y altas de campo sin Hallazgos quedan fuera del documento.', }, ]; diff --git a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx index 5dd5dc3..cb7dbd7 100644 --- a/web-v2/src/features/inspections/InspectionActMediaPanel.tsx +++ b/web-v2/src/features/inspections/InspectionActMediaPanel.tsx @@ -1,13 +1,11 @@ import { useEffect, useState } from 'react'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { - getAssetMediaBlob, getInspectionFindingEvidenceBlob, - listInspectionActFieldMedia, listInspectionFindingEvidence, listInspectionFindings, } from '../../lib/api'; -import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api'; +import type { InspectionFinding, InspectionFindingEvidence } from '../../lib/api'; import { formatDate } from '../../lib/format'; type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] }; @@ -30,50 +28,44 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio ; } -function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) { +function Finding({ item }: { item: FindingWithPhotos }) { const { finding, photos } = item; return
{finding.code}

{finding.title}

{finding.asset.name} · {finding.asset.code}

{finding.description}

{finding.legalBasis &&

Normativa: {finding.legalBasis}

} {finding.severity != null &&

Gravedad {finding.severity}/10

} - {(photos.length > 0 || assetPhotos.length > 0) &&
+ {photos.length > 0 &&
{photos.map((photo) => )} - {assetPhotos.map((photo) => )}
} - {photos.length === 0 && assetPhotos.length === 0 && Sin fotografías vinculadas.} + {photos.length === 0 && Sin fotografías vinculadas al hallazgo.}
; } export function InspectionActMediaPanel({ actId }: { actId: string }) { const [items, setItems] = useState([]); - const [assetPhotos, setAssetPhotos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { let active = true; setLoading(true); setError(''); - Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])]) - .then(async ([findings, media]) => { + listInspectionFindings(actId) + .then(async (findings) => { const records = await Promise.all(findings.map(async (finding) => ({ - finding, photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'), + finding, + photos: (await listInspectionFindingEvidence(finding.id)).filter((evidence) => evidence.kind === 'PHOTO' && evidence.purpose === 'OBSERVATION'), }))); - if (!active) return; - setItems(records); - setAssetPhotos(media.filter((photo) => photo.kind === 'PHOTO')); + if (active) setItems(records); }) .catch((requestError) => active && setError(errorMessage(requestError))) .finally(() => active && setLoading(false)); return () => { active = false; }; }, [actId]); - const findingAssetIds = new Set(items.map((item) => item.finding.asset.id)); - const otherPhotos = assetPhotos.filter((photo) => !findingAssetIds.has(photo.assetId)); return
-

Hallazgos del Acta

Cada hallazgo reúne su descripción y las fotos tomadas en campo.

{items.length}
+

Hallazgos del Acta

El Acta muestra únicamente Hallazgos y la evidencia fotográfica vinculada a cada uno.

{items.length}
{error && {error}} {loading ? : items.length ? -
{items.map((item) => photo.assetId === item.finding.asset.id)} />)}
: +
{items.map((item) => )}
: } - {!loading && otherPhotos.length > 0 &&

Fotos de otras instalaciones

{otherPhotos.map((photo) => )}
}
; } diff --git a/web-v2/src/pages/CompanySignaturePage.tsx b/web-v2/src/pages/CompanySignaturePage.tsx index edbb826..3898d05 100644 --- a/web-v2/src/pages/CompanySignaturePage.tsx +++ b/web-v2/src/pages/CompanySignaturePage.tsx @@ -13,13 +13,6 @@ interface PublicFinding { recurrenceOfFindingId: string | null; } -interface PublicInventory { - id: string; - code: string; - name: string; - typeName: string | null; -} - interface PublicSignatureView { invitation: { id: string; @@ -41,7 +34,6 @@ interface PublicSignatureView { documentNumber: string | null; position: string | null; }; - inventories: PublicInventory[]; findings: PublicFinding[]; consent: string; allowedActions: string[]; @@ -351,12 +343,6 @@ export function CompanySignaturePage() {

Contenido del Acta

{view.act.summary && <>Resumen

{view.act.summary}

} {view.act.observations && <>Observaciones

{view.act.observations}

} -

Inventario inspeccionado

- {view.inventories.length === 0 ?

Sin Inventario detallado.

: view.inventories.map((item) => ( -
- {item.code} · {item.name}{item.typeName &&
{item.typeName}
} -
- ))}

Hallazgos

{view.findings.length === 0 ?

El Acta no contiene Hallazgos.

: view.findings.map((finding) => (
diff --git a/web-v2/src/pages/InspectionActEditorPage.tsx b/web-v2/src/pages/InspectionActEditorPage.tsx index 00c5949..f826de1 100644 --- a/web-v2/src/pages/InspectionActEditorPage.tsx +++ b/web-v2/src/pages/InspectionActEditorPage.tsx @@ -93,7 +93,6 @@ export function InspectionActEditorPage() { {meaningfulSummary &&

Lo actuado

{meaningfulSummary}

} {act.observations &&

Observaciones

{act.observations}

} - {act.assets.length > 0 &&

Instalaciones inspeccionadas

{act.assets.map((asset) => `${asset.name} (${asset.code})`).join(' · ')}

} } {act && }