Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69aeb52040 | ||
|
|
8d5ffc86f9 | ||
|
|
8266bd8669 | ||
|
|
510a5fbca3 | ||
|
|
215f443f71 | ||
|
|
ff297c8d93 |
@@ -4,7 +4,13 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- 'feature/**'
|
||||
- 'fix/**'
|
||||
- 'chore/**'
|
||||
- 'release/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -52,9 +58,10 @@ jobs:
|
||||
- run: npm run build
|
||||
|
||||
contract:
|
||||
name: Docker / scripts contract
|
||||
name: Docker / migrations / production images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [api, web]
|
||||
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate shell scripts
|
||||
@@ -347,3 +354,22 @@ jobs:
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
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",
|
||||
"version": "0.29.0-9",
|
||||
"version": "0.29.0-11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-9",
|
||||
"version": "0.29.0-11",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-9",
|
||||
"version": "0.29.0-11",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type UploadedInspectionSignatureFile,
|
||||
} from './inspection-signature-file';
|
||||
|
||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4';
|
||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
||||
const CONSENT_VERSION = 'F4-1';
|
||||
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
||||
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
||||
@@ -896,6 +896,8 @@ export class InspectionClosingService {
|
||||
'id',act.id,
|
||||
'code',act.code,
|
||||
'status',act.status,
|
||||
'actYear',act.act_year,
|
||||
'actNumber',act.act_number,
|
||||
'occurredAt',act.occurred_at,
|
||||
'title',act.title,
|
||||
'summary',act.summary,
|
||||
@@ -910,6 +912,7 @@ export class InspectionClosingService {
|
||||
'id',visit.id,
|
||||
'code',visit.code,
|
||||
'status',visit.status,
|
||||
'scopeAssetId',visit.scope_asset_id,
|
||||
'operationalAreaId',visit.operational_area_id,
|
||||
'operatorCompanyId',visit.operator_company_id,
|
||||
'leadInspectorUserId',visit.lead_inspector_user_id,
|
||||
@@ -922,43 +925,74 @@ export class InspectionClosingService {
|
||||
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
||||
if (!act) throw actNotFound();
|
||||
const responsible = await this.requireResponsible(manager, actId);
|
||||
const [contextRow] = await manager.query(`
|
||||
SELECT
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS company,
|
||||
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',department.id,'code',department.code,'name',department.name) END AS department,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS area,
|
||||
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',scope.id,'code',scope.code,'name',scope.name,'typeCode',scope_type.code,'typeName',scope_type.name) END AS scope,
|
||||
CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',lead.id,'username',lead.username,'firstName',lead.first_name,'lastName',lead.last_name,'email',lead.email) END AS "leadInspector"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
||||
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
||||
WHERE act.id=$1
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
const inspectors = await manager.query(`
|
||||
SELECT member.id,member.username,member.first_name AS "firstName",member.last_name AS "lastName",member.email
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
JOIN inspection_visit_members link ON link.visit_id=act.visit_id AND link.included=true
|
||||
JOIN users member ON member.id=link.user_id
|
||||
WHERE act.id=$1
|
||||
ORDER BY CASE WHEN member.id=visit.lead_inspector_user_id THEN 0 ELSE 1 END,
|
||||
member.last_name,member.first_name,member.username
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
const context = { ...(contextRow ?? {}), inspectors };
|
||||
const inventories = await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
|
||||
type.code AS "typeCode",type.name AS "typeName"
|
||||
FROM inspection_act_assets link
|
||||
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
|
||||
ORDER BY asset.code,asset.id
|
||||
WITH RECURSIVE selected AS (
|
||||
SELECT asset.id,asset.code,asset.name,asset.common_name,asset.parent_id,asset.current_version,type.code AS type_code,type.name AS type_name,
|
||||
family.code AS family_code,family.name AS family_name
|
||||
FROM inspection_act_assets link
|
||||
JOIN assets asset ON asset.id=link.asset_id
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_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')
|
||||
), lineage AS (
|
||||
SELECT selected.id AS root_id,selected.id,selected.code,selected.name,selected.type_code,selected.type_name,selected.parent_id,0 AS depth FROM selected
|
||||
UNION ALL
|
||||
SELECT lineage.root_id,parent.id,parent.code,parent.name,parent_type.code,parent_type.name,parent.parent_id,lineage.depth+1
|
||||
FROM lineage JOIN assets parent ON parent.id=lineage.parent_id JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
||||
WHERE lineage.depth<8
|
||||
)
|
||||
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
||||
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
||||
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
||||
FROM selected ORDER BY selected.code,selected.id
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
const findings = await manager.query(`
|
||||
SELECT
|
||||
finding.id,
|
||||
finding.finding_number AS "findingNumber",
|
||||
finding.code,
|
||||
finding.status,
|
||||
finding.asset_id AS "assetId",
|
||||
finding.catalog_item_id AS "catalogItemId",
|
||||
finding.title,
|
||||
finding.description,
|
||||
finding.legal_basis AS "legalBasis",
|
||||
finding.severity,
|
||||
finding.is_recurrence AS "isRecurrence",
|
||||
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||
finding.correction_due_on AS "correctionDueOn",
|
||||
finding.current_version AS "currentVersion"
|
||||
SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
|
||||
finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
|
||||
finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
|
||||
finding.suggested_severity AS "suggestedSeverity",finding.severity,
|
||||
finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||
finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
|
||||
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',catalog.id,'code',catalog.code,'sourceNumber',catalog.source_number,'title',catalog.title,'categoryName',category.name,'revision',finding.catalog_revision) END AS catalog,
|
||||
CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence
|
||||
FROM inspection_findings finding
|
||||
LEFT JOIN finding_catalog_items catalog ON catalog.id=finding.catalog_item_id
|
||||
LEFT JOIN finding_categories category ON category.id=catalog.category_id
|
||||
LEFT JOIN inspection_findings antecedent ON antecedent.id=finding.recurrence_of_finding_id
|
||||
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||
ORDER BY finding.finding_number,finding.id
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
return {
|
||||
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
||||
lockedAt: lockedAt.toISOString(),
|
||||
act: act.act,
|
||||
responsible,
|
||||
inventories,
|
||||
findings,
|
||||
};
|
||||
return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
|
||||
}
|
||||
|
||||
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
||||
|
||||
@@ -14,6 +14,19 @@ export interface ActPdfImage {
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ActPdfContext {
|
||||
companyName?: string | null;
|
||||
departmentName?: string | null;
|
||||
areaName?: string | null;
|
||||
scopeName?: string | null;
|
||||
scopeCode?: string | null;
|
||||
scopeTypeName?: string | null;
|
||||
scopeTypeCode?: string | null;
|
||||
yacimientoName?: string | null;
|
||||
yacimientoCode?: string | null;
|
||||
leadInspectorName?: string | null;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -25,24 +38,60 @@ function text(value: unknown, fallback = ''): string {
|
||||
}
|
||||
function date(value: unknown): string {
|
||||
const parsed = new Date(String(value ?? ''));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString('es-AR', { timeZone: 'America/Argentina/Mendoza', dateStyle: 'short', timeStyle: 'short' }) : '-';
|
||||
return Number.isFinite(parsed.getTime())
|
||||
? new Intl.DateTimeFormat('es-AR', {
|
||||
timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(parsed)
|
||||
: '-';
|
||||
}
|
||||
function fullName(value: Record<string, unknown>): string {
|
||||
return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username);
|
||||
}
|
||||
function isPlaceholder(value: unknown): boolean {
|
||||
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
||||
}
|
||||
function urgency(value: unknown): string {
|
||||
return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-';
|
||||
}
|
||||
function dayType(value: unknown): string {
|
||||
return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : '';
|
||||
}
|
||||
function deadlineBasis(value: unknown): string {
|
||||
return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : '';
|
||||
}
|
||||
function manifestation(value: unknown): string {
|
||||
return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : '';
|
||||
}
|
||||
function signatureStatus(value: unknown): string {
|
||||
return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó';
|
||||
}
|
||||
|
||||
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
||||
export async function buildInspectionActPdf(snapshot: Record<string, unknown>, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> {
|
||||
export async function buildInspectionActPdf(
|
||||
snapshot: Record<string, unknown>,
|
||||
images: ActPdfImage[] = [],
|
||||
fallbackContext: ActPdfContext = {},
|
||||
): Promise<{ buffer: Buffer; sha256: string }> {
|
||||
const sealed = asRecord(snapshot);
|
||||
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||
const act = asRecord(locked.act);
|
||||
const inspection = asRecord(act.inspection ?? act.visit);
|
||||
const context = asRecord(locked.context);
|
||||
const company = asRecord(context.company);
|
||||
const department = asRecord(context.department);
|
||||
const area = asRecord(context.area);
|
||||
const scope = asRecord(context.scope);
|
||||
const leadInspector = asRecord(context.leadInspector);
|
||||
const inspectors = asArray(context.inspectors);
|
||||
const responsible = asRecord(locked.responsible);
|
||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||
const inventories = asArray(locked.inventories);
|
||||
const findings = asArray(locked.findings);
|
||||
const signatures = asArray(sealed.signatures);
|
||||
const seal = asRecord(sealed.seal);
|
||||
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
||||
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
||||
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
||||
@@ -50,6 +99,9 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
|
||||
doc.on('data', (part: Buffer) => chunks.push(part));
|
||||
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
||||
const blue = '#162D69';
|
||||
const ink = '#202939';
|
||||
const muted = '#5A667C';
|
||||
|
||||
const header = () => {
|
||||
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
||||
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
||||
@@ -59,76 +111,189 @@ export async function buildInspectionActPdf(snapshot: Record<string, unknown>, i
|
||||
};
|
||||
doc.on('pageAdded', header);
|
||||
header();
|
||||
|
||||
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
||||
const heading = (label: string) => { need(48); doc.moveDown(1); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveDown(0.35); };
|
||||
const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); };
|
||||
const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); };
|
||||
const section = (label: string) => {
|
||||
need(46);
|
||||
doc.moveDown(0.9);
|
||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase());
|
||||
doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke();
|
||||
doc.moveDown(0.55);
|
||||
};
|
||||
const body = (value: unknown) => {
|
||||
need(24);
|
||||
doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 });
|
||||
doc.moveDown(0.4);
|
||||
};
|
||||
const label = (name: string, value: unknown, allowEmpty = false) => {
|
||||
const rendered = text(value);
|
||||
if (!rendered && !allowEmpty) return;
|
||||
need(24);
|
||||
doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true });
|
||||
doc.font('body').text(rendered || '-');
|
||||
doc.moveDown(0.28);
|
||||
};
|
||||
const subheading = (value: string) => {
|
||||
need(32);
|
||||
doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value);
|
||||
doc.moveDown(0.25);
|
||||
};
|
||||
const image = (entry: ActPdfImage, caption: string) => {
|
||||
need(235);
|
||||
const y = doc.y;
|
||||
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
||||
doc.y = y + 195;
|
||||
doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
||||
doc.font('body').fontSize(8).fillColor(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
||||
doc.moveDown(0.5);
|
||||
};
|
||||
|
||||
doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
||||
doc.moveDown(0.5);
|
||||
doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
||||
doc.moveDown(0.15);
|
||||
doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva');
|
||||
|
||||
section('1. Identificación del Acta');
|
||||
label('Código del Acta', act.code);
|
||||
if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`);
|
||||
label('Inspección', inspection.code);
|
||||
label('Empresa inspeccionada', context.companyName);
|
||||
label('Área', context.areaName);
|
||||
label('Yacimiento o instalación', context.scopeName);
|
||||
label('Fecha y hora', date(act.occurredAt));
|
||||
label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : '');
|
||||
label('Fecha y hora de actuación', date(act.occurredAt));
|
||||
label('Estado documental', 'Sellada');
|
||||
label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion);
|
||||
|
||||
section('2. Contexto territorial y operativo');
|
||||
label('Empresa / Operadora', company.name ?? fallbackContext.companyName);
|
||||
label('Departamento', department.name ?? fallbackContext.departmentName);
|
||||
label('Área', area.name ?? fallbackContext.areaName);
|
||||
const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path))
|
||||
.filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento');
|
||||
const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase();
|
||||
const yacimientoNames = Array.from(new Set([
|
||||
...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []),
|
||||
...pathYacimientos.map((node) => text(node.name)),
|
||||
text(fallbackContext.yacimientoName),
|
||||
].filter(Boolean)));
|
||||
const yacimientoCodes = Array.from(new Set([
|
||||
...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []),
|
||||
...pathYacimientos.map((node) => text(node.code)),
|
||||
text(fallbackContext.yacimientoCode),
|
||||
].filter(Boolean)));
|
||||
if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', '));
|
||||
if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', '));
|
||||
if (scopeTypeCode && scopeTypeCode !== 'yacimiento') {
|
||||
const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`;
|
||||
label('Alcance de la inspección', scopeLabel);
|
||||
}
|
||||
label('Inicio efectivo de la inspección', date(inspection.actualStartedAt));
|
||||
|
||||
section('3. Intervinientes');
|
||||
const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || '';
|
||||
label('Inspector/a responsable', leadName);
|
||||
const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName);
|
||||
if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', '));
|
||||
label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente');
|
||||
label('Representante de la empresa', responsible.fullName);
|
||||
label('DNI', responsible.documentNumber);
|
||||
const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' ');
|
||||
label('Documento', document);
|
||||
label('Cargo o función', responsible.position);
|
||||
heading('Lo actuado');
|
||||
label('Correo electrónico', responsible.email);
|
||||
label('Teléfono', responsible.phone);
|
||||
if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason);
|
||||
|
||||
section('4. Datos del Acta');
|
||||
label('Objeto / Denominación', act.title);
|
||||
label('Urgencia', urgency(act.urgency));
|
||||
if (act.deadlineDays != null) {
|
||||
const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim();
|
||||
label('Plazo', duration);
|
||||
label('Cómputo del plazo', deadlineBasis(act.deadlineBasis));
|
||||
if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt));
|
||||
if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt));
|
||||
}
|
||||
subheading('Descripción de lo actuado');
|
||||
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)}`);
|
||||
if (text(act.observations)) {
|
||||
subheading('Observaciones generales');
|
||||
body(act.observations);
|
||||
}
|
||||
heading('Hallazgos y fotografías');
|
||||
if (!findings.length) body('No se registraron hallazgos.');
|
||||
const shownAssetPhotos = new Set<string>();
|
||||
|
||||
section('5. Hallazgos');
|
||||
if (!findings.length) body('No se registraron hallazgos en esta Acta.');
|
||||
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)}` : ''}`);
|
||||
const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
|
||||
const catalog = asRecord(finding.catalog);
|
||||
const recurrence = asRecord(finding.recurrence);
|
||||
const path = asArray(inventory.path);
|
||||
const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
|
||||
const number = text(finding.findingNumber, '?');
|
||||
need(100);
|
||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
||||
doc.moveDown(0.2);
|
||||
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
||||
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
||||
if (route) label('Ubicación / Ruta jerárquica', route);
|
||||
label('Denominación del hallazgo', finding.title);
|
||||
subheading('Qué se constató');
|
||||
body(finding.description);
|
||||
if (text(catalog.id)) {
|
||||
const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · ');
|
||||
label('Referencia de catálogo', source);
|
||||
} else {
|
||||
label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo');
|
||||
}
|
||||
doc.moveDown(0.4);
|
||||
if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary);
|
||||
if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis);
|
||||
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
||||
if (finding.isRecurrence) {
|
||||
label('Reincidencia', 'Sí');
|
||||
if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`);
|
||||
} else {
|
||||
label('Reincidencia', 'No');
|
||||
}
|
||||
const photos = images.filter((item) => item.findingId === text(finding.id));
|
||||
if (photos.length) {
|
||||
subheading(`Evidencia fotográfica (${photos.length})`);
|
||||
for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||
} else {
|
||||
label('Evidencia fotográfica', 'Sin fotografías asociadas');
|
||||
}
|
||||
doc.moveDown(0.7);
|
||||
}
|
||||
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');
|
||||
|
||||
section('6. Firmas y manifestaciones');
|
||||
for (const signature of signatures) {
|
||||
const name = text(signature.signerName);
|
||||
const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa';
|
||||
const status = text(signature.status);
|
||||
label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`);
|
||||
if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement);
|
||||
if (status === 'REFUSED') label('Motivo de negativa', signature.reason);
|
||||
const inspector = text(signature.signerType) === 'INSPECTOR';
|
||||
const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
|
||||
subheading(`${role}: ${name}`);
|
||||
const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
|
||||
if (signedDocument) label('Documento', signedDocument);
|
||||
label('Cargo o función', signature.position);
|
||||
label('Resultado', signatureStatus(signature.status));
|
||||
if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation));
|
||||
if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement);
|
||||
if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason);
|
||||
label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt));
|
||||
if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web');
|
||||
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
|
||||
if (signatureImage) { need(100); const y = doc.y; doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); doc.y = y + 66; }
|
||||
if (signatureImage) {
|
||||
need(100);
|
||||
const y = doc.y;
|
||||
doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] });
|
||||
doc.y = y + 66;
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
heading('Integridad del Acta');
|
||||
|
||||
section('7. Integridad y cierre');
|
||||
label('Contenido bloqueado', date(locked.lockedAt));
|
||||
label('Sellado en servidor', date(seal.serverSealedAt));
|
||||
label('Fecha informada por dispositivo', date(seal.deviceSealedAt));
|
||||
label('Modo de cierre', seal.uploadMode);
|
||||
body(`SHA-256 del cierre: ${hash}`);
|
||||
body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`);
|
||||
need(25);
|
||||
doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y);
|
||||
body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
|
||||
need(28);
|
||||
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
|
||||
|
||||
doc.end();
|
||||
const buffer = await done;
|
||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { buildInspectionActPdf, type ActPdfImage } from './inspection-act-pdf-builder';
|
||||
import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder';
|
||||
import { renderableInspectionImage } from './inspection-document-images';
|
||||
|
||||
@Injectable()
|
||||
@@ -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`;
|
||||
@@ -96,7 +96,7 @@ export class InspectionActPdfService {
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_act_consolidated_pdf_revisions
|
||||
WHERE act_id=$1 AND template_version=2
|
||||
WHERE act_id=$1 AND template_version=3
|
||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (existing) {
|
||||
const buffer = await this.verifiedImage(this.root, existing);
|
||||
@@ -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`;
|
||||
@@ -126,12 +126,12 @@ export class InspectionActPdfService {
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
|
||||
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING
|
||||
VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING
|
||||
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||
const [saved] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=2
|
||||
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3
|
||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||
if (!saved) throw this.storageError();
|
||||
return {
|
||||
@@ -141,7 +141,7 @@ export class InspectionActPdfService {
|
||||
}
|
||||
|
||||
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
||||
if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||
size_bytes AS "sizeBytes",sha256
|
||||
@@ -152,17 +152,38 @@ export class InspectionActPdfService {
|
||||
return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' };
|
||||
}
|
||||
|
||||
private async actContext(actId: string): Promise<{ companyName: string | null; areaName: string | null; scopeName: string | null }> {
|
||||
private async actContext(actId: string): Promise<ActPdfContext> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName"
|
||||
SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName",
|
||||
scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode",
|
||||
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName",
|
||||
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode",
|
||||
btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName"
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
||||
LEFT JOIN assets department ON department.id=area.parent_id
|
||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
||||
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
||||
LEFT JOIN LATERAL (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id
|
||||
FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id
|
||||
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
||||
UNION
|
||||
SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id
|
||||
FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id
|
||||
)
|
||||
SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name,
|
||||
string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code
|
||||
FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id
|
||||
WHERE lower(ancestor_type.code)='yacimiento'
|
||||
) yacimiento_from_findings ON true
|
||||
WHERE act.id=$1
|
||||
`, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>;
|
||||
return row ?? { companyName: null, areaName: null, scopeName: null };
|
||||
`, [actId]) as ActPdfContext[];
|
||||
return row ?? {};
|
||||
}
|
||||
|
||||
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
||||
@@ -176,7 +197,7 @@ export class InspectionActPdfService {
|
||||
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 };
|
||||
const findings = await this.dataSource.query(`
|
||||
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
||||
@@ -189,7 +210,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 +222,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"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-9';
|
||||
export const API_PHASE = 'F6.9';
|
||||
export const API_VERSION = '0.29.0-11';
|
||||
export const API_PHASE = 'F6.10';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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, /section\('5\. Hallazgos'\)/);
|
||||
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/);
|
||||
});
|
||||
|
||||
test('Acta PDF explains each Hallazgo with frozen territorial, technical and recurrence context', () => {
|
||||
const pdf = api('inspection-reports/inspection-act-pdf-builder.ts');
|
||||
const closing = api('inspection-closing/inspection-closing.service.ts');
|
||||
assert.match(pdf, /Contexto territorial y operativo/);
|
||||
assert.match(pdf, /Inspector\/a responsable/);
|
||||
assert.match(pdf, /Tipo de instalación/);
|
||||
assert.match(pdf, /Qué se constató/);
|
||||
assert.match(pdf, /Referencia de catálogo/);
|
||||
assert.match(pdf, /Normativa \/ Base legal/);
|
||||
assert.match(pdf, /Reincidencia/);
|
||||
assert.match(pdf, /Alcance de la inspección/);
|
||||
assert.match(pdf, /scopeTypeCode/);
|
||||
assert.match(pdf, /Antecedente relacionado/);
|
||||
assert.match(closing, /DH-ACT-LIFECYCLE-V5/);
|
||||
assert.match(closing, /AS department/);
|
||||
assert.match(closing, /AS "leadInspector"/);
|
||||
assert.match(closing, /AS "installationTypeName"/);
|
||||
assert.match(closing, /AS recurrence/);
|
||||
const service = api('inspection-reports/inspection-act-pdf.service.ts');
|
||||
assert.match(service, /yacimiento_from_findings/);
|
||||
});
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.9 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.9');
|
||||
test('health metadata reports the current F6.10 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.10');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-9');
|
||||
});
|
||||
assert.equal(API_VERSION, '0.29.0-11');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 38/);
|
||||
assert.match(gradle, /versionName = "0\.19\.10"/);
|
||||
assert.match(gradle, /versionCode = 39/);
|
||||
assert.match(gradle, /versionName = "0\.19\.11"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
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.match(acts, /Text\("Agregar Hallazgo"\)/);
|
||||
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 prepareSelectedAct\(urgency: String\)/);
|
||||
assert.match(vm, /fun prepareSelectedAct\(urgency: String, narrative: String\)/);
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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')));
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# F6.10 · Acta PDF completa y autoexplicativa
|
||||
|
||||
## Objetivo
|
||||
|
||||
Ordenar el Acta consolidada para que pueda comprenderse sin consultar el sistema y reforzar la explicación de cada Hallazgo.
|
||||
|
||||
## Contenido documental
|
||||
|
||||
El PDF se organiza en siete bloques: identificación, contexto territorial y operativo, intervinientes, datos del Acta, Hallazgos, firmas/manifestaciones e integridad/cierre.
|
||||
|
||||
Cada Hallazgo congela y presenta el elemento afectado, tipo de instalación, ruta Departamento → Área → Yacimiento → Instalación/Subinstalación, denominación, constatación, referencia de catálogo u OTROS, criterio técnico disponible, base legal, gravedad, reincidencia/antecedente y fotografías vinculadas directamente al Hallazgo.
|
||||
|
||||
No se incorporan al Acta altas de campo, instalaciones ni fotografías sin Hallazgo. Los plazos y la urgencia continúan perteneciendo al Acta, no a los Hallazgos.
|
||||
|
||||
## Inmutabilidad
|
||||
|
||||
El snapshot de cierre sube a `DH-ACT-LIFECYCLE-V5` para congelar contexto territorial, operadora, inspectores, ruta técnica y metadatos de catálogo/reincidencia. El PDF consolidado pasa a plantilla documental v3, conservando disponibles las revisiones v1/v2 ya emitidas.
|
||||
|
||||
## Validación
|
||||
|
||||
- API typecheck: OK
|
||||
- API tests: 470/470
|
||||
- API build: OK
|
||||
- PDF de muestra: 3 páginas, lectura completa y fechas Mendoza en formato administrativo de 24 horas.
|
||||
+13
-17
@@ -16,8 +16,6 @@ STAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
||||
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
||||
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
||||
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
|
||||
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
|
||||
PHASE="bootstrap"
|
||||
PREV_SHA=""
|
||||
TARGET_SHA=""
|
||||
@@ -32,7 +30,6 @@ cleanup() {
|
||||
set +e
|
||||
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
||||
rm -rf "$STAGE"
|
||||
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
publish_status() {
|
||||
@@ -159,11 +156,13 @@ fi
|
||||
|
||||
PREV_SHA="$(git rev-parse HEAD)"
|
||||
PHASE="fetch"
|
||||
git fetch origin "$DEPLOY_REF"
|
||||
git fetch origin "$DEPLOY_REF" main
|
||||
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
|
||||
echo "Actual: $PREV_SHA"
|
||||
echo "Objetivo: $TARGET_SHA"
|
||||
echo "Main: $MAIN_SHA"
|
||||
|
||||
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
||||
echo "Producción ya está en el commit autorizado."
|
||||
@@ -171,6 +170,13 @@ if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
||||
exit 0
|
||||
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
|
||||
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
||||
false
|
||||
@@ -193,19 +199,9 @@ while IFS= read -r -d '' script; do
|
||||
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
||||
|
||||
echo
|
||||
echo "========== TEST API CANDIDATA =========="
|
||||
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
|
||||
docker run --rm \
|
||||
-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
|
||||
echo "========== PREFLIGHT DE DEPLOY =========="
|
||||
echo "Gitea Actions ya validó tests, migraciones e imágenes de producción."
|
||||
echo "El VPS valida únicamente composición, scripts, backup, migraciones reales, recreación y health."
|
||||
|
||||
PHASE="backup"
|
||||
echo
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.23.0-6",
|
||||
"version": "0.23.0-7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
</figure>;
|
||||
}
|
||||
|
||||
function Finding({ item, assetPhotos }: { item: FindingWithPhotos; assetPhotos: InspectionActFieldMedia[] }) {
|
||||
function Finding({ item }: { item: FindingWithPhotos }) {
|
||||
const { finding, photos } = item;
|
||||
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>
|
||||
<p className="inspection-finding-description">{finding.description}</p>
|
||||
{finding.legalBasis && <p><strong>Normativa:</strong> {finding.legalBasis}</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} />)}
|
||||
{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>}
|
||||
{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>;
|
||||
}
|
||||
|
||||
export function InspectionActMediaPanel({ actId }: { actId: string }) {
|
||||
const [items, setItems] = useState<FindingWithPhotos[]>([]);
|
||||
const [assetPhotos, setAssetPhotos] = useState<InspectionActFieldMedia[]>([]);
|
||||
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 <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>}
|
||||
{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." />}
|
||||
{!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>;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<h2>Contenido del Acta</h2>
|
||||
{view.act.summary && <><strong>Resumen</strong><p>{view.act.summary}</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>
|
||||
{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 }}>
|
||||
|
||||
@@ -93,7 +93,6 @@ export function InspectionActEditorPage() {
|
||||
</div>
|
||||
{meaningfulSummary && <div><h2>Lo actuado</h2><p>{meaningfulSummary}</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>}
|
||||
|
||||
{act && <InspectionActMediaPanel actId={act.id} />}
|
||||
|
||||
Reference in New Issue
Block a user