Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5ffc86f9 | ||
|
|
8266bd8669 | ||
|
|
510a5fbca3 | ||
|
|
215f443f71 | ||
|
|
ff297c8d93 |
@@ -4,7 +4,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feature/**'
|
||||||
|
- 'fix/**'
|
||||||
|
- 'chore/**'
|
||||||
|
- 'release/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -52,9 +58,10 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
contract:
|
contract:
|
||||||
name: Docker / scripts contract
|
name: Docker / migrations / production images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [api, web]
|
needs: [api, web]
|
||||||
|
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Validate shell scripts
|
- name: Validate shell scripts
|
||||||
@@ -347,3 +354,22 @@ jobs:
|
|||||||
docker image rm "$image" >/dev/null 2>&1 || true
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
- name: Build production images
|
- name: Build production images
|
||||||
run: docker compose --env-file .env.example build api migrate web
|
run: docker compose --env-file .env.example build api migrate web
|
||||||
|
|
||||||
|
promote-deploy:
|
||||||
|
name: Promote verified main to deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [api, web, contract]
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Fast-forward deploy to the verified commit
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
git fetch origin deploy main
|
||||||
|
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||||
|
git merge-base --is-ancestor origin/deploy "$GITHUB_SHA"
|
||||||
|
git push origin "$GITHUB_SHA:refs/heads/deploy"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-9",
|
"version": "0.29.0-10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-9",
|
"version": "0.29.0-10",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-9",
|
"version": "0.29.0-10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ export class CompanySignatureInviteService {
|
|||||||
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
||||||
const locked = invite.lockedSnapshot ?? {};
|
const locked = invite.lockedSnapshot ?? {};
|
||||||
const act = this.record(locked.act);
|
const act = this.record(locked.act);
|
||||||
const inventories = this.records(locked.inventories);
|
|
||||||
const findings = this.records(locked.findings).map((finding) => ({
|
const findings = this.records(locked.findings).map((finding) => ({
|
||||||
id: finding.id,
|
id: finding.id,
|
||||||
code: finding.code,
|
code: finding.code,
|
||||||
@@ -296,12 +295,6 @@ export class CompanySignatureInviteService {
|
|||||||
documentNumber: invite.recipientDocumentNumber,
|
documentNumber: invite.recipientDocumentNumber,
|
||||||
position: invite.recipientPosition,
|
position: invite.recipientPosition,
|
||||||
},
|
},
|
||||||
inventories: inventories.map((inventory) => ({
|
|
||||||
id: inventory.id,
|
|
||||||
code: inventory.code,
|
|
||||||
name: inventory.name,
|
|
||||||
typeName: inventory.typeName ?? inventory.typeCode ?? null,
|
|
||||||
})),
|
|
||||||
findings,
|
findings,
|
||||||
consent: REMOTE_COMPANY_CONSENT,
|
consent: REMOTE_COMPANY_CONSENT,
|
||||||
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
||||||
|
|||||||
@@ -929,6 +929,12 @@ export class InspectionClosingService {
|
|||||||
JOIN assets asset ON asset.id=link.asset_id
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
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
|
ORDER BY asset.code,asset.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
const findings = await manager.query(`
|
const findings = await manager.query(`
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
|
|||||||
const act = asRecord(locked.act);
|
const act = asRecord(locked.act);
|
||||||
const inspection = asRecord(act.inspection ?? act.visit);
|
const inspection = asRecord(act.inspection ?? act.visit);
|
||||||
const responsible = asRecord(locked.responsible);
|
const responsible = asRecord(locked.responsible);
|
||||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
|
||||||
const findings = asArray(locked.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures = asArray(sealed.signatures);
|
const signatures = asArray(sealed.signatures);
|
||||||
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||||
@@ -87,32 +86,19 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
|
|||||||
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
|
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.`);
|
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 (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');
|
heading('Hallazgos y fotografías');
|
||||||
if (!findings.length) body('No se registraron hallazgos.');
|
if (!findings.length) body('No se registraron hallazgos.');
|
||||||
const shownAssetPhotos = new Set<string>();
|
|
||||||
for (const finding of findings) {
|
for (const finding of findings) {
|
||||||
need(75);
|
need(75);
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
|
doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
|
||||||
label('Descripción', finding.description);
|
label('Descripción', finding.description);
|
||||||
if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
|
if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
|
||||||
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
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.findingId === text(finding.id))) {
|
||||||
for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) {
|
image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||||
if (shownAssetPhotos.has(photo.id)) continue;
|
|
||||||
shownAssetPhotos.add(photo.id);
|
|
||||||
image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
|
||||||
}
|
}
|
||||||
doc.moveDown(0.4);
|
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');
|
heading('Intervinientes y firmas');
|
||||||
for (const signature of signatures) {
|
for (const signature of signatures) {
|
||||||
const name = text(signature.signerName);
|
const name = text(signature.signerName);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class InspectionActPdfService {
|
|||||||
`, [actId]);
|
`, [actId]);
|
||||||
try {
|
try {
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
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 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${actId}.pdf`;
|
const storedName = `${actId}.pdf`;
|
||||||
const originalName = `${row.code}.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',
|
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
|
||||||
});
|
});
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
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 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
|
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
|
||||||
const originalName = `${row.code}-consolidada.pdf`;
|
const originalName = `${row.code}-consolidada.pdf`;
|
||||||
@@ -176,7 +176,7 @@ export class InspectionActPdfService {
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fieldImages(actId: string): Promise<ActPdfImage[]> {
|
async fieldImages(actId: string, includeAssetPhotos = true): Promise<ActPdfImage[]> {
|
||||||
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
|
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(`
|
const findings = await this.dataSource.query(`
|
||||||
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
||||||
@@ -189,7 +189,7 @@ export class InspectionActPdfService {
|
|||||||
AND evidence.created_at<=act.locked_at
|
AND evidence.created_at<=act.locked_at
|
||||||
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
|
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
|
||||||
`, [actId]) as ImageRow[];
|
`, [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,
|
SELECT media.id,asset.id AS "assetId", asset.name AS title,
|
||||||
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
|
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
|
||||||
media.sha256,media.size_bytes AS "sizeBytes"
|
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'
|
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
|
WHERE act.id=$1 AND capture.created_at<=act.locked_at
|
||||||
ORDER BY capture.device_captured_at,media.id
|
ORDER BY capture.device_captured_at,media.id
|
||||||
`, [actId]) as ImageRow[];
|
`, [actId]) as ImageRow[] : [];
|
||||||
const signatures = await this.dataSource.query(`
|
const signatures = await this.dataSource.query(`
|
||||||
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
|
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
|
||||||
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
|
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.29.0-9';
|
export const API_VERSION = '0.29.0-10';
|
||||||
export const API_PHASE = 'F6.9';
|
export const API_PHASE = 'F6.9';
|
||||||
|
|||||||
@@ -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</);
|
||||||
|
assert.doesNotMatch(page, /Inventario inspeccionado/);
|
||||||
|
assert.doesNotMatch(page, /view\.inventories/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta sealed snapshot excludes Inventory that has no Hallazgo and PDF skips standalone asset media', () => {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@ test('health metadata reports the current F6.9 release', () => {
|
|||||||
assert.equal(API_PHASE, 'F6.9');
|
assert.equal(API_PHASE, 'F6.9');
|
||||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||||
assert.equal(API_VERSION, pkg.version);
|
assert.equal(API_VERSION, pkg.version);
|
||||||
assert.equal(API_VERSION, '0.29.0-9');
|
assert.equal(API_VERSION, '0.29.0-10');
|
||||||
});
|
});
|
||||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
|||||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
test('F6.3 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 = 38/);
|
assert.match(gradle, /versionCode = 39/);
|
||||||
assert.match(gradle, /versionName = "0\.19\.10"/);
|
assert.match(gradle, /versionName = "0\.19\.11"/);
|
||||||
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"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', ()
|
|||||||
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
||||||
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
||||||
assert.match(acts, /model\.createAct\(\)/);
|
assert.match(acts, /model\.createAct\(\)/);
|
||||||
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
|
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency, actNarrative\)/);
|
||||||
assert.match(vm, /fun createAct\(\)/);
|
assert.match(vm, /fun createAct\(\)/);
|
||||||
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
|
assert.match(vm, /fun prepareSelectedAct\(urgency: String, narrative: String\)/);
|
||||||
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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\)/);
|
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 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 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 withUnlinked = await buildInspectionActPdf(sealed, [evidence, unlinked]);
|
||||||
const linkedOnly = await buildInspectionActPdf(sealed, [evidence]);
|
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(),
|
const word = buildInspectionReportWord({ code: 'INF-OTHER', title: 'Informe', generatedAt: new Date(),
|
||||||
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] });
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] });
|
||||||
assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS')));
|
assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS')));
|
||||||
|
|||||||
+13
-17
@@ -16,8 +16,6 @@ STAMP="$(date +%Y%m%d_%H%M%S)"
|
|||||||
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
||||||
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
||||||
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
||||||
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
|
|
||||||
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
|
|
||||||
PHASE="bootstrap"
|
PHASE="bootstrap"
|
||||||
PREV_SHA=""
|
PREV_SHA=""
|
||||||
TARGET_SHA=""
|
TARGET_SHA=""
|
||||||
@@ -32,7 +30,6 @@ cleanup() {
|
|||||||
set +e
|
set +e
|
||||||
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
||||||
rm -rf "$STAGE"
|
rm -rf "$STAGE"
|
||||||
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_status() {
|
publish_status() {
|
||||||
@@ -159,11 +156,13 @@ fi
|
|||||||
|
|
||||||
PREV_SHA="$(git rev-parse HEAD)"
|
PREV_SHA="$(git rev-parse HEAD)"
|
||||||
PHASE="fetch"
|
PHASE="fetch"
|
||||||
git fetch origin "$DEPLOY_REF"
|
git fetch origin "$DEPLOY_REF" main
|
||||||
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
||||||
|
MAIN_SHA="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
echo "Actual: $PREV_SHA"
|
echo "Actual: $PREV_SHA"
|
||||||
echo "Objetivo: $TARGET_SHA"
|
echo "Objetivo: $TARGET_SHA"
|
||||||
|
echo "Main: $MAIN_SHA"
|
||||||
|
|
||||||
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
||||||
echo "Producción ya está en el commit autorizado."
|
echo "Producción ya está en el commit autorizado."
|
||||||
@@ -171,6 +170,13 @@ if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$TARGET_SHA" != "$MAIN_SHA" ]; then
|
||||||
|
echo "ERROR: deploy no coincide con main; se rechaza una promoción manual o incompleta."
|
||||||
|
echo "deploy: $TARGET_SHA"
|
||||||
|
echo "main: $MAIN_SHA"
|
||||||
|
false
|
||||||
|
fi
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
||||||
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
||||||
false
|
false
|
||||||
@@ -193,19 +199,9 @@ while IFS= read -r -d '' script; do
|
|||||||
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "========== TEST API CANDIDATA =========="
|
echo "========== PREFLIGHT DE DEPLOY =========="
|
||||||
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
|
echo "Gitea Actions ya validó tests, migraciones e imágenes de producción."
|
||||||
docker run --rm \
|
echo "El VPS valida únicamente composición, scripts, backup, migraciones reales, recreación y health."
|
||||||
-v "$STAGE/api-v3/test:/app/test:ro" \
|
|
||||||
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
|
||||||
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
|
|
||||||
-v "$STAGE/web-v2:/web-v2:ro" \
|
|
||||||
-v "$STAGE/android-app:/android-app:ro" \
|
|
||||||
"$API_TEST_IMAGE" npm test </dev/null
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "========== BUILD WEB CANDIDATA =========="
|
|
||||||
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
|
|
||||||
|
|
||||||
PHASE="backup"
|
PHASE="backup"
|
||||||
echo
|
echo
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-6",
|
"version": "0.23.0-7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-6",
|
"version": "0.23.0-7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "6.4.1",
|
"maplibre-gl": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-6",
|
"version": "0.23.0-7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Alcance y constatación',
|
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',
|
title: 'Hallazgos',
|
||||||
@@ -34,7 +34,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Anexos',
|
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.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.23.0-6';
|
export const APP_VERSION = '0.23.0-7';
|
||||||
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
|
||||||
import {
|
import {
|
||||||
getAssetMediaBlob,
|
|
||||||
getInspectionFindingEvidenceBlob,
|
getInspectionFindingEvidenceBlob,
|
||||||
listInspectionActFieldMedia,
|
|
||||||
listInspectionFindingEvidence,
|
listInspectionFindingEvidence,
|
||||||
listInspectionFindings,
|
listInspectionFindings,
|
||||||
} from '../../lib/api';
|
} from '../../lib/api';
|
||||||
import type { InspectionActFieldMedia, InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
|
import type { InspectionFinding, InspectionFindingEvidence } from '../../lib/api';
|
||||||
import { formatDate } from '../../lib/format';
|
import { formatDate } from '../../lib/format';
|
||||||
|
|
||||||
type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
|
type FindingWithPhotos = { finding: InspectionFinding; photos: InspectionFindingEvidence[] };
|
||||||
@@ -30,50 +28,44 @@ function Photo({ id, title, caption, load }: { id: string; title: string; captio
|
|||||||
</figure>;
|
</figure>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) {
|
function Finding({ item }: { item: FindingWithPhotos }) {
|
||||||
const { finding, photos } = item;
|
const { finding, photos } = item;
|
||||||
return <article className="act-finding-record">
|
return <article className="act-finding-record">
|
||||||
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
|
<div className="inspection-finding-heading"><div><span className="eyebrow">{finding.code}</span><h3>{finding.title}</h3><p>{finding.asset.name} · {finding.asset.code}</p></div></div>
|
||||||
<p className="inspection-finding-description">{finding.description}</p>
|
<p className="inspection-finding-description">{finding.description}</p>
|
||||||
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</p>}
|
||||||
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
{finding.severity != null && <p>Gravedad {finding.severity}/10</p>}
|
||||||
{(photos.length > 0 || assetPhotos.length > 0) && <div className="act-finding-photos">
|
{photos.length > 0 && <div className="act-finding-photos">
|
||||||
{photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
|
{photos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.title} caption={`Hallazgo · ${formatDate(photo.capturedAt || photo.createdAt)}${photo.latitude != null && photo.longitude != null ? ` · GPS ${photo.latitude.toFixed(6)}, ${photo.longitude.toFixed(6)}` : ''}`} load={getInspectionFindingEvidenceBlob} />)}
|
||||||
{assetPhotos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || finding.asset.name} caption={`Inventario · ${formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}`} load={getAssetMediaBlob} />)}
|
|
||||||
</div>}
|
</div>}
|
||||||
{photos.length === 0 && assetPhotos.length === 0 && <small className="muted">Sin fotografías vinculadas.</small>}
|
{photos.length === 0 && <small className="muted">Sin fotografías vinculadas al hallazgo.</small>}
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
||||||
const [items, setItems] = useState<FindingWithPhotos[]>([]);
|
const [items, setItems] = useState<FindingWithPhotos[]>([]);
|
||||||
const [assetPhotos, setAssetPhotos] = useState<InspectionActFieldMedia[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
Promise.all([listInspectionFindings(actId), listInspectionActFieldMedia(actId).catch(() => [])])
|
listInspectionFindings(actId)
|
||||||
.then(async ([findings, media]) => {
|
.then(async (findings) => {
|
||||||
const records = await Promise.all(findings.map(async (finding) => ({
|
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;
|
if (active) setItems(records);
|
||||||
setItems(records);
|
|
||||||
setAssetPhotos(media.filter((photo) => photo.kind === 'PHOTO'));
|
|
||||||
})
|
})
|
||||||
.catch((requestError) => active && setError(errorMessage(requestError)))
|
.catch((requestError) => active && setError(errorMessage(requestError)))
|
||||||
.finally(() => active && setLoading(false));
|
.finally(() => active && setLoading(false));
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [actId]);
|
}, [actId]);
|
||||||
const findingAssetIds = new Set(items.map((item) => item.finding.asset.id));
|
|
||||||
const otherPhotos = assetPhotos.filter((photo) => !findingAssetIds.has(photo.assetId));
|
|
||||||
return <section className="panel act-media-panel">
|
return <section className="panel act-media-panel">
|
||||||
<div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">Cada hallazgo reúne su descripción y las fotos tomadas en campo.</p></div><span className="count-pill">{items.length}</span></div>
|
<div className="panel-heading"><div><h2>Hallazgos del Acta</h2><p className="section-copy">El Acta muestra únicamente Hallazgos y la evidencia fotográfica vinculada a cada uno.</p></div><span className="count-pill">{items.length}</span></div>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
|
{loading ? <LoadingBlock label="Cargando hallazgos y fotografías…" /> : items.length ?
|
||||||
<div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} assetPhotos={assetPhotos.filter((photo) => photo.assetId === item.finding.asset.id)} />)}</div> :
|
<div className="act-finding-list">{items.map((item) => <Finding key={item.finding.id} item={item} />)}</div> :
|
||||||
<EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
|
<EmptyState title="Sin hallazgos" text="Esta Acta no contiene hallazgos sincronizados." />}
|
||||||
{!loading && otherPhotos.length > 0 && <div className="act-other-asset-photos"><h3>Fotos de otras instalaciones</h3><div className="act-finding-photos">{otherPhotos.map((photo) => <Photo key={photo.id} id={photo.id} title={photo.title || 'Instalación inspeccionada'} caption={`${photo.title || 'Instalación'} · ${formatDate(photo.fieldCapturedAt || photo.capturedAt || photo.createdAt)}`} load={getAssetMediaBlob} />)}</div></div>}
|
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ interface PublicFinding {
|
|||||||
recurrenceOfFindingId: string | null;
|
recurrenceOfFindingId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicInventory {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
typeName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PublicSignatureView {
|
interface PublicSignatureView {
|
||||||
invitation: {
|
invitation: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,7 +34,6 @@ interface PublicSignatureView {
|
|||||||
documentNumber: string | null;
|
documentNumber: string | null;
|
||||||
position: string | null;
|
position: string | null;
|
||||||
};
|
};
|
||||||
inventories: PublicInventory[];
|
|
||||||
findings: PublicFinding[];
|
findings: PublicFinding[];
|
||||||
consent: string;
|
consent: string;
|
||||||
allowedActions: string[];
|
allowedActions: string[];
|
||||||
@@ -351,12 +343,6 @@ export function CompanySignaturePage() {
|
|||||||
<h2>Contenido del Acta</h2>
|
<h2>Contenido del Acta</h2>
|
||||||
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</p></>}
|
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</p></>}
|
||||||
{view.act.observations && <><strong>Observaciones</strong><p>{view.act.observations}</p></>}
|
{view.act.observations && <><strong>Observaciones</strong><p>{view.act.observations}</p></>}
|
||||||
<h3>Inventario inspeccionado</h3>
|
|
||||||
{view.inventories.length === 0 ? <p>Sin Inventario detallado.</p> : view.inventories.map((item) => (
|
|
||||||
<div key={item.id} style={{ padding: '9px 0', borderBottom: '1px solid #e3e9ed' }}>
|
|
||||||
<strong>{item.code} · {item.name}</strong>{item.typeName && <div style={{ color: '#5c707b' }}>{item.typeName}</div>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<h3 style={{ marginTop: 24 }}>Hallazgos</h3>
|
<h3 style={{ marginTop: 24 }}>Hallazgos</h3>
|
||||||
{view.findings.length === 0 ? <p>El Acta no contiene Hallazgos.</p> : view.findings.map((finding) => (
|
{view.findings.length === 0 ? <p>El Acta no contiene Hallazgos.</p> : view.findings.map((finding) => (
|
||||||
<article key={finding.id} style={{ padding: 14, marginBottom: 10, border: '1px solid #d8e1e7', borderRadius: 10 }}>
|
<article key={finding.id} style={{ padding: 14, marginBottom: 10, border: '1px solid #d8e1e7', borderRadius: 10 }}>
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ export function InspectionActEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
{meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</p></div>}
|
{meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</p></div>}
|
||||||
{act.observations && <div><h3>Observaciones</h3><p>{act.observations}</p></div>}
|
{act.observations && <div><h3>Observaciones</h3><p>{act.observations}</p></div>}
|
||||||
{act.assets.length > 0 && <div><h3>Instalaciones inspeccionadas</h3><p>{act.assets.map((asset) => `${asset.name} (${asset.code})`).join(' · ')}</p></div>}
|
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{act && <InspectionActMediaPanel actId={act.id} />}
|
{act && <InspectionActMediaPanel actId={act.id} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user