Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29208f4e1f | ||
|
|
4523aef932 | ||
|
|
011b3e7fcb | ||
|
|
9b7d67bea2 | ||
|
|
f0b92c00e1 | ||
|
|
cab9cd7c01 | ||
|
|
c9575cc520 | ||
|
|
69aeb52040 | ||
|
|
8d5ffc86f9 | ||
|
|
8266bd8669 | ||
|
|
510a5fbca3 | ||
|
|
215f443f71 | ||
|
|
ff297c8d93 |
@@ -1,5 +1,5 @@
|
|||||||
name: Android CI / RC
|
name: Android CI / RC
|
||||||
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
|
# F6.13 client-demo barrier: lint + real tests + debug artifact + release compile.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||||
sha256sum "$apk" > "${apk}.sha256"
|
sha256sum "$apk" > "${apk}.sha256"
|
||||||
{
|
{
|
||||||
echo "phase=F6.8"
|
echo "phase=F6.13"
|
||||||
echo "version=$version"
|
echo "version=$version"
|
||||||
echo "versionCode=$code"
|
echo "versionCode=$code"
|
||||||
echo "commit=$GITHUB_SHA"
|
echo "commit=$GITHUB_SHA"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# DH Inspección Android · release de campo 0.19.11
|
# DH Inspección Android · release de campo 0.19.12
|
||||||
|
|
||||||
## Candidata vigente
|
## Candidata vigente
|
||||||
|
|
||||||
- `versionName`: **0.19.11**; `versionCode`: **39**.
|
- `versionName`: **0.19.12**; `versionCode`: **40**.
|
||||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
|
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-13 / WEB 0.23.0-10.
|
||||||
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
||||||
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
|
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
|
||||||
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
|
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 39
|
versionCode = 40
|
||||||
versionName = "0.19.11"
|
versionName = "0.19.12"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
|
|||||||
@Test
|
@Test
|
||||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||||
assertEquals(39, BuildConfig.VERSION_CODE)
|
assertEquals(40, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.19.12-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-9",
|
"version": "0.29.0-13",
|
||||||
"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-13",
|
||||||
"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-13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
|
|||||||
FieldBriefingController,
|
FieldBriefingController,
|
||||||
],
|
],
|
||||||
providers: [ActAdministrationService, FieldBriefingService],
|
providers: [ActAdministrationService, FieldBriefingService],
|
||||||
|
exports: [ActAdministrationService],
|
||||||
})
|
})
|
||||||
export class ActAdministrationModule {}
|
export class ActAdministrationModule {}
|
||||||
|
|||||||
@@ -114,17 +114,46 @@ export class ActAdministrationService {
|
|||||||
return act;
|
return act;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
async setDeadline(
|
||||||
|
actId: string,
|
||||||
|
dto: SetActResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
const rows = await this.dataSource.query(
|
return this.dataSource.transaction(async (manager) => {
|
||||||
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
const rows = await manager.query(
|
||||||
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
`INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
|
||||||
);
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||||
return rows[0];
|
[randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
|
||||||
|
);
|
||||||
|
const projected = await manager.query(
|
||||||
|
`UPDATE inspection_findings
|
||||||
|
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1 AND status<>'VOIDED'
|
||||||
|
RETURNING id`,
|
||||||
|
[actId, dto.responseDueOn, principal.userId],
|
||||||
|
) as Array<{ id: string }>;
|
||||||
|
await this.audit.record({
|
||||||
|
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
|
||||||
|
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
|
||||||
|
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
|
||||||
|
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
|
||||||
|
}, manager);
|
||||||
|
return { ...rows[0], projectedFindingCount: projected.length };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
async addResponse(
|
||||||
|
actId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
let storedName: string | null = null;
|
let storedName: string | null = null;
|
||||||
let sha256: string | null = null;
|
let sha256: string | null = null;
|
||||||
@@ -138,12 +167,12 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
`INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
||||||
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
[randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
||||||
);
|
);
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
|
||||||
return rows[0];
|
return rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||||
@@ -151,8 +180,13 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async responseContent(responseId: string) {
|
async responseContent(responseId: string, reportId?: string) {
|
||||||
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
|
||||||
|
[responseId, reportId ?? null],
|
||||||
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||||
const filePath = join(STORAGE, row.storedName);
|
const filePath = join(STORAGE, row.storedName);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
|
||||||
|
name = 'F611ReportResponseWorkflow1790139000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION validate_report_act_relation()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_reports report
|
||||||
|
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
|
||||||
|
name = 'F612AdminReportPermission1790142600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO role_permissions(role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM roles role
|
||||||
|
JOIN permissions permission ON permission.code='inspection_reports.generate'
|
||||||
|
WHERE role.code='admin'
|
||||||
|
ON CONFLICT(role_id, permission_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM role_permissions role_permission
|
||||||
|
USING roles role, permissions permission
|
||||||
|
WHERE role_permission.role_id=role.id
|
||||||
|
AND role_permission.permission_id=permission.id
|
||||||
|
AND role.code='admin'
|
||||||
|
AND permission.code='inspection_reports.generate'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type UploadedInspectionSignatureFile,
|
type UploadedInspectionSignatureFile,
|
||||||
} from './inspection-signature-file';
|
} 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 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 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.';
|
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,
|
'id',act.id,
|
||||||
'code',act.code,
|
'code',act.code,
|
||||||
'status',act.status,
|
'status',act.status,
|
||||||
|
'actYear',act.act_year,
|
||||||
|
'actNumber',act.act_number,
|
||||||
'occurredAt',act.occurred_at,
|
'occurredAt',act.occurred_at,
|
||||||
'title',act.title,
|
'title',act.title,
|
||||||
'summary',act.summary,
|
'summary',act.summary,
|
||||||
@@ -910,6 +912,7 @@ export class InspectionClosingService {
|
|||||||
'id',visit.id,
|
'id',visit.id,
|
||||||
'code',visit.code,
|
'code',visit.code,
|
||||||
'status',visit.status,
|
'status',visit.status,
|
||||||
|
'scopeAssetId',visit.scope_asset_id,
|
||||||
'operationalAreaId',visit.operational_area_id,
|
'operationalAreaId',visit.operational_area_id,
|
||||||
'operatorCompanyId',visit.operator_company_id,
|
'operatorCompanyId',visit.operator_company_id,
|
||||||
'leadInspectorUserId',visit.lead_inspector_user_id,
|
'leadInspectorUserId',visit.lead_inspector_user_id,
|
||||||
@@ -922,43 +925,74 @@ export class InspectionClosingService {
|
|||||||
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
||||||
if (!act) throw actNotFound();
|
if (!act) throw actNotFound();
|
||||||
const responsible = await this.requireResponsible(manager, actId);
|
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(`
|
const inventories = await manager.query(`
|
||||||
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
|
WITH RECURSIVE selected AS (
|
||||||
type.code AS "typeCode",type.name AS "typeName"
|
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,
|
||||||
FROM inspection_act_assets link
|
family.code AS family_code,family.name AS family_name
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
FROM inspection_act_assets link
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
ORDER BY asset.code,asset.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>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
const findings = await manager.query(`
|
const findings = await manager.query(`
|
||||||
SELECT
|
SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
|
||||||
finding.id,
|
finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
|
||||||
finding.finding_number AS "findingNumber",
|
finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
|
||||||
finding.code,
|
finding.suggested_severity AS "suggestedSeverity",finding.severity,
|
||||||
finding.status,
|
finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||||
finding.asset_id AS "assetId",
|
finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
|
||||||
finding.catalog_item_id AS "catalogItemId",
|
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,
|
||||||
finding.title,
|
CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence
|
||||||
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"
|
|
||||||
FROM inspection_findings finding
|
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'
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
ORDER BY finding.finding_number,finding.id
|
ORDER BY finding.finding_number,finding.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
return {
|
return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
|
||||||
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
||||||
lockedAt: lockedAt.toISOString(),
|
|
||||||
act: act.act,
|
|
||||||
responsible,
|
|
||||||
inventories,
|
|
||||||
findings,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetInspectionReportResponseDeadlineDto {
|
||||||
|
@IsDateString()
|
||||||
|
responseDueOn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
@@ -14,6 +14,19 @@ export interface ActPdfImage {
|
|||||||
buffer: Buffer;
|
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> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as 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 {
|
function date(value: unknown): string {
|
||||||
const parsed = new Date(String(value ?? ''));
|
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 {
|
function isPlaceholder(value: unknown): boolean {
|
||||||
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
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.
|
// %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 sealed = asRecord(snapshot);
|
||||||
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||||
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 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 responsible = asRecord(locked.responsible);
|
||||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
const inventories = asArray(locked.inventories);
|
||||||
const findings = asArray(locked.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures = asArray(sealed.signatures);
|
const signatures = asArray(sealed.signatures);
|
||||||
|
const seal = asRecord(sealed.seal);
|
||||||
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||||
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
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 });
|
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', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
||||||
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.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));
|
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 done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
||||||
const blue = '#162D69';
|
const blue = '#162D69';
|
||||||
|
const ink = '#202939';
|
||||||
|
const muted = '#5A667C';
|
||||||
|
|
||||||
const header = () => {
|
const header = () => {
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
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);
|
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);
|
doc.on('pageAdded', header);
|
||||||
header();
|
header();
|
||||||
|
|
||||||
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
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 section = (label: string) => {
|
||||||
const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); };
|
need(46);
|
||||||
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); };
|
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) => {
|
const image = (entry: ActPdfImage, caption: string) => {
|
||||||
need(235);
|
need(235);
|
||||||
const y = doc.y;
|
const y = doc.y;
|
||||||
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
||||||
doc.y = y + 195;
|
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.moveDown(0.5);
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
||||||
doc.moveDown(0.5);
|
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('Inspección', inspection.code);
|
||||||
label('Empresa inspeccionada', context.companyName);
|
label('Fecha y hora de actuación', date(act.occurredAt));
|
||||||
label('Área', context.areaName);
|
label('Estado documental', 'Sellada');
|
||||||
label('Yacimiento o instalación', context.scopeName);
|
label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion);
|
||||||
label('Fecha y hora', date(act.occurredAt));
|
|
||||||
label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : '');
|
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('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);
|
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);
|
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 (text(act.observations)) {
|
||||||
if (inventories.length) {
|
subheading('Observaciones generales');
|
||||||
heading('Instalaciones inspeccionadas');
|
body(act.observations);
|
||||||
for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`);
|
|
||||||
}
|
}
|
||||||
heading('Hallazgos y fotografías');
|
|
||||||
if (!findings.length) body('No se registraron hallazgos.');
|
section('5. Hallazgos');
|
||||||
const shownAssetPhotos = new Set<string>();
|
if (!findings.length) body('No se registraron hallazgos en esta Acta.');
|
||||||
for (const finding of findings) {
|
for (const finding of findings) {
|
||||||
need(75);
|
const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
|
const catalog = asRecord(finding.catalog);
|
||||||
label('Descripción', finding.description);
|
const recurrence = asRecord(finding.recurrence);
|
||||||
if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
|
const path = asArray(inventory.path);
|
||||||
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
|
||||||
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)}` : ''}`);
|
const number = text(finding.findingNumber, '?');
|
||||||
for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) {
|
need(100);
|
||||||
if (shownAssetPhotos.has(photo.id)) continue;
|
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
||||||
shownAssetPhotos.add(photo.id);
|
doc.moveDown(0.2);
|
||||||
image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
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) {
|
section('6. Firmas y manifestaciones');
|
||||||
heading('Otras instalaciones inspeccionadas');
|
|
||||||
for (const photo of otherAssetPhotos) image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
|
||||||
}
|
|
||||||
heading('Intervinientes y firmas');
|
|
||||||
for (const signature of signatures) {
|
for (const signature of signatures) {
|
||||||
const name = text(signature.signerName);
|
const name = text(signature.signerName);
|
||||||
const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa';
|
const inspector = text(signature.signerType) === 'INSPECTOR';
|
||||||
const status = text(signature.status);
|
const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
|
||||||
label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`);
|
subheading(`${role}: ${name}`);
|
||||||
if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement);
|
const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
|
||||||
if (status === 'REFUSED') label('Motivo de negativa', signature.reason);
|
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));
|
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 cierre: ${hash}`);
|
||||||
body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`);
|
body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
|
||||||
need(25);
|
need(28);
|
||||||
doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y);
|
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
|
||||||
|
|
||||||
doc.end();
|
doc.end();
|
||||||
const buffer = await done;
|
const buffer = await done;
|
||||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
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';
|
import { renderableInspectionImage } from './inspection-document-images';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -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`;
|
||||||
@@ -96,7 +96,7 @@ export class InspectionActPdfService {
|
|||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
size_bytes AS "sizeBytes",sha256
|
size_bytes AS "sizeBytes",sha256
|
||||||
FROM inspection_act_consolidated_pdf_revisions
|
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 }>;
|
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const buffer = await this.verifiedImage(this.root, 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',
|
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`;
|
||||||
@@ -126,12 +126,12 @@ export class InspectionActPdfService {
|
|||||||
});
|
});
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
|
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]);
|
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
|
||||||
const [saved] = await this.dataSource.query(`
|
const [saved] = await this.dataSource.query(`
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
size_bytes AS "sizeBytes",sha256
|
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 }>;
|
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
||||||
if (!saved) throw this.storageError();
|
if (!saved) throw this.storageError();
|
||||||
return {
|
return {
|
||||||
@@ -141,7 +141,7 @@ export class InspectionActPdfService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
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(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
SELECT stored_name AS "storedName",original_name AS "originalName",
|
||||||
size_bytes AS "sizeBytes",sha256
|
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' };
|
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(`
|
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
|
FROM inspection_acts act
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
LEFT JOIN assets company ON company.id=visit.operator_company_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 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 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
|
WHERE act.id=$1
|
||||||
`, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>;
|
`, [actId]) as ActPdfContext[];
|
||||||
return row ?? { companyName: null, areaName: null, scopeName: null };
|
return row ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
||||||
@@ -176,7 +197,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 +210,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 +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'
|
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"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
|
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||||
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
|
|
||||||
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly administration: ActAdministrationService,
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
||||||
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setResponseDeadline(
|
||||||
|
reportId: string,
|
||||||
|
dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
|
||||||
|
await this.administration.setDeadline(
|
||||||
|
report.actId,
|
||||||
|
{ responseDueOn: dto.responseDueOn, reason },
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
report.id,
|
||||||
|
);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCompanyResponse(
|
||||||
|
reportId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async companyResponseContent(reportId: string, responseId: string) {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
return this.administration.responseContent(responseId, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -300,6 +340,15 @@ export class InspectionReportWorkflowService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const report = await this.lockReport(manager, reportId);
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
if (
|
||||||
|
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
|
||||||
|
&& report.status !== InspectionReportStatus.OFFICIALIZED
|
||||||
|
) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
|
||||||
|
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
const occurredAt = new Date(dto.occurredAt);
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_report_follow_ups (
|
INSERT INTO inspection_report_follow_ups (
|
||||||
@@ -407,6 +456,18 @@ export class InspectionReportWorkflowService {
|
|||||||
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
|
||||||
|
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
||||||
const [row] = await manager.query(`
|
const [row] = await manager.query(`
|
||||||
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
|
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
@@ -22,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
|
|||||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||||
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
import {
|
import {
|
||||||
InspectionReportWorkflowService,
|
InspectionReportWorkflowService,
|
||||||
MAX_INSPECTION_REPORT_FILE_BYTES,
|
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||||
@@ -136,6 +139,49 @@ export class InspectionReportsController {
|
|||||||
return this.workflow.officialize(id, dto, file, principal, request);
|
return this.workflow.officialize(id, dto, file, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/response-deadline')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
setResponseDeadline(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.setResponseDeadline(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/company-responses')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
addCompanyResponse(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateActCompanyResponseDto,
|
||||||
|
@UploadedFile() file: UploadedActResponseFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/company-responses/:responseId/content')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async companyResponseContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const item = await this.workflow.companyResponseContent(id, responseId);
|
||||||
|
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', 'application/pdf');
|
||||||
|
response.setHeader('Content-Length', String(item.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/follow-ups')
|
@Get(':id/follow-ups')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { ActAdministrationModule } from '../act-administration/act-administration.module';
|
||||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||||
@@ -12,7 +13,7 @@ import { InspectionReportsService } from './inspection-reports.service';
|
|||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule, ActAdministrationModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
InspectionReportsController,
|
InspectionReportsController,
|
||||||
InspectionActReportController,
|
InspectionActReportController,
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
|
|||||||
|
|
||||||
export interface InspectionReportView extends InspectionReportListItem {
|
export interface InspectionReportView extends InspectionReportListItem {
|
||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
deadlineReason: string | null;
|
||||||
|
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
|
||||||
|
companyResponses: Array<{
|
||||||
|
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
|
||||||
|
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
|
||||||
|
}>;
|
||||||
|
findings: Array<{
|
||||||
|
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingInspectionReportItem {
|
export interface PendingInspectionReportItem {
|
||||||
@@ -491,7 +501,38 @@ export class InspectionReportsService {
|
|||||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
||||||
[id],
|
[id],
|
||||||
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
||||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
const deadlines = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_deadline_events
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
|
||||||
|
const currentDeadline = deadlines[0] ?? null;
|
||||||
|
const companyResponses = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
|
||||||
|
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
|
||||||
|
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY received_on DESC,created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
|
||||||
|
const findings = await manager.query(`
|
||||||
|
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
|
||||||
|
$2::date AS "responseDueOn"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN assets asset ON asset.id=finding.asset_id
|
||||||
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
|
ORDER BY finding.finding_number,finding.id
|
||||||
|
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
frozenSnapshot: snapshot.frozenSnapshot,
|
||||||
|
responseDueOn: currentDeadline?.responseDueOn ?? null,
|
||||||
|
deadlineReason: currentDeadline?.reason ?? null,
|
||||||
|
deadlines,
|
||||||
|
companyResponses,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { connect as connectNet, Socket } from 'node:net';
|
import { connect as connectNet, Socket } from 'node:net';
|
||||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { SmtpSecurityMode } from '../database/entities';
|
import { SmtpSecurityMode } from '../database/entities';
|
||||||
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
|
|||||||
|
|
||||||
private encryptionKey():Buffer{
|
private encryptionKey():Buffer{
|
||||||
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
||||||
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
|
if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
|
||||||
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
||||||
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
|
if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.29.0-9';
|
export const API_VERSION = '0.29.0-13';
|
||||||
export const API_PHASE = 'F6.9';
|
export const API_PHASE = 'F6.12';
|
||||||
|
|||||||
@@ -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 { resolve } from 'node:path';
|
||||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||||
|
|
||||||
test('health metadata reports the current F6.9 release', () => {
|
test('health metadata reports the current F6.12 release', () => {
|
||||||
assert.equal(API_PHASE, 'F6.9');
|
assert.equal(API_PHASE, 'F6.12');
|
||||||
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-13');
|
||||||
});
|
});
|
||||||
@@ -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 = 40/);
|
||||||
assert.match(gradle, /versionName = "0\.19\.10"/);
|
assert.match(gradle, /versionName = "0\.19\.12"/);
|
||||||
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"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
|||||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||||
|
|
||||||
assert.equal(visibleVersion, pkg.version);
|
assert.equal(visibleVersion, pkg.version);
|
||||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.9/);
|
assert.match(version, /APP_PHASE\s*=\s*'F6\.13/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
|
||||||
|
assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/);
|
||||||
|
assert.match(page, /GEDO no se consulta automáticamente/);
|
||||||
|
assert.match(page, /no crea respuestas ni vencimientos automáticamente/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => {
|
||||||
|
const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts');
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/);
|
||||||
|
assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 projects one Act response deadline to every non-voided finding', () => {
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
const reports = api('inspection-reports/inspection-reports.service.ts');
|
||||||
|
assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/);
|
||||||
|
assert.match(administration, /sharedDeadline: true/);
|
||||||
|
assert.match(reports, /\$2::date AS "responseDueOn"/);
|
||||||
|
assert.match(reports, /currentDeadline\?\.responseDueOn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 enables formal responses only after the official GEDO PDF exists', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const controller = api('inspection-reports/inspection-reports.controller.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /requireOfficializedReport\(reportId\)/);
|
||||||
|
assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/);
|
||||||
|
assert.match(controller, /@Post\(':id\/company-responses'\)/);
|
||||||
|
assert.match(controller, /@Patch\(':id\/response-deadline'\)/);
|
||||||
|
assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/);
|
||||||
|
assert.match(page, /Vencimiento común del Acta/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 separates formal company responses from internal report follow-up notes', () => {
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(page, /RESPUESTAS DE EMPRESA/);
|
||||||
|
assert.match(page, /No usar este bloque para respuestas formales de empresa/);
|
||||||
|
assert.match(page, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
|
||||||
|
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => {
|
||||||
|
const client = web('src/lib/api.ts');
|
||||||
|
const map = web('src/features/map/DhMap.tsx');
|
||||||
|
const editor = web('src/features/map/AssetGeometryEditor.tsx');
|
||||||
|
assert.match(client, /type: 'Point'/);
|
||||||
|
assert.match(client, /type: 'LineString'/);
|
||||||
|
assert.match(client, /type: 'Polygon'/);
|
||||||
|
assert.match(client, /geometryPayload/);
|
||||||
|
assert.match(map, /feature\.geometry\.type === 'Point'/);
|
||||||
|
assert.match(editor, /setType\(value\.geometryType\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.12 reports missing SMTP encryption configuration explicitly', () => {
|
||||||
|
const smtp = api('src/inspection-reports/smtp-delivery.service.ts');
|
||||||
|
assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/);
|
||||||
|
assert.match(smtp, /ServiceUnavailableException/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.12 grants report management to the system administrator role', () => {
|
||||||
|
const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts');
|
||||||
|
assert.match(migration, /role\.code='admin'/);
|
||||||
|
assert.match(migration, /inspection_reports\.generate/);
|
||||||
|
assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/);
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# F6.11 · GEDO y respuestas de Informes
|
||||||
|
|
||||||
|
## Regla funcional
|
||||||
|
|
||||||
|
GEDO no está integrado como respuesta automática. El Informe permanece en preparación hasta que un usuario carga manualmente el identificador IF, la fecha y el PDF oficial emitido por GEDO.
|
||||||
|
|
||||||
|
La oficialización documental no crea una respuesta de empresa ni activa un vencimiento por sí sola.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. El Acta sellada origina un INF editable.
|
||||||
|
2. El usuario carga manualmente IF + PDF oficial GEDO.
|
||||||
|
3. El Informe queda oficializado e inmutable en su contenido técnico.
|
||||||
|
4. Desde el Informe se define un único vencimiento de respuesta para su Acta.
|
||||||
|
5. Ese vencimiento se proyecta a todos los Hallazgos no anulados del Acta.
|
||||||
|
6. Las respuestas de empresa se registran después de la oficialización y quedan relacionadas con `report_id` + `act_id`.
|
||||||
|
7. Cada respuesta puede incluir fecha de recepción, detalle, compromiso, contacto y PDF.
|
||||||
|
|
||||||
|
Los vencimientos y respuestas históricas permanecen append-only y no se reescriben para completar relaciones nuevas.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# F6.12 · Geometrías y correo SMTP
|
||||||
|
|
||||||
|
- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir.
|
||||||
|
- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON.
|
||||||
|
- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real.
|
||||||
|
- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico.
|
||||||
|
- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total.
|
||||||
+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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY tsconfig*.json vite.config.ts index.html ./
|
COPY tsconfig*.json vite.config.ts index.html ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY scripts ./scripts
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location = /assets/maplibre-gl-worker.mjs {
|
||||||
|
default_type application/javascript;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-6",
|
"version": "0.23.0-10",
|
||||||
"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-10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "6.4.1",
|
"maplibre-gl": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-6",
|
"version": "0.23.0-10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
|
||||||
|
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
copyFileSync(source, target);
|
||||||
|
chmodSync(target, 0o644);
|
||||||
|
const bytes = statSync(target).size;
|
||||||
|
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
|
||||||
|
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
|
||||||
@@ -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-10';
|
||||||
export const APP_PHASE = 'F6.9 · Actas e informes consolidados';
|
export const APP_PHASE = 'F6.13 · Mapa operativo';
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,22 +41,22 @@ function localDateTime(value: string | number | Date): string {
|
|||||||
|
|
||||||
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
function geometryVertices(geometry: GeoJsonGeometry | null): Position[] {
|
||||||
if (!geometry) return [];
|
if (!geometry) return [];
|
||||||
if (geometry.type === 'POINT') return [geometry.coordinates];
|
if (geometry.type === 'Point') return [geometry.coordinates];
|
||||||
if (geometry.type === 'LINESTRING') return geometry.coordinates;
|
if (geometry.type === 'LineString') return geometry.coordinates;
|
||||||
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
return geometry.coordinates[0]?.slice(0, -1) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
function draftGeometry(type: AssetGeometryType, vertices: Position[]): GeoJsonGeometry | null {
|
||||||
if (type === 'POINT') return vertices[0] ? { type, coordinates: vertices[0] } : null;
|
if (type === 'POINT') return vertices[0] ? { type: 'Point', coordinates: vertices[0] } : null;
|
||||||
if (type === 'LINESTRING') return vertices.length >= 2 ? { type, coordinates: vertices } : null;
|
if (type === 'LINESTRING') return vertices.length >= 2 ? { type: 'LineString', coordinates: vertices } : null;
|
||||||
if (vertices.length < 3) return null;
|
if (vertices.length < 3) return null;
|
||||||
return { type, coordinates: [[...vertices, vertices[0]!]] };
|
return { type: 'Polygon', coordinates: [[...vertices, vertices[0]!]] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
function drawingCollection(geometry: GeoJsonGeometry | null, vertices: Position[]) {
|
||||||
const features: unknown[] = [];
|
const features: unknown[] = [];
|
||||||
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
if (geometry) features.push({ type: 'Feature', properties: { kind: 'shape' }, geometry });
|
||||||
if (geometry?.type !== 'POINT') {
|
if (geometry?.type !== 'Point') {
|
||||||
vertices.forEach((coordinates, index) => features.push({
|
vertices.forEach((coordinates, index) => features.push({
|
||||||
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
type: 'Feature', properties: { kind: 'vertex', index: index + 1 },
|
||||||
geometry: { type: 'Point', coordinates },
|
geometry: { type: 'Point', coordinates },
|
||||||
@@ -187,7 +187,7 @@ export function AssetGeometryEditor({
|
|||||||
const applyStored = (value: AssetGeometry | null) => {
|
const applyStored = (value: AssetGeometry | null) => {
|
||||||
setStored(value);
|
setStored(value);
|
||||||
if (value) {
|
if (value) {
|
||||||
setType(value.geometry.type);
|
setType(value.geometryType);
|
||||||
setVertices(geometryVertices(value.geometry));
|
setVertices(geometryVertices(value.geometry));
|
||||||
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
setAccuracyM(value.accuracyM == null ? '' : String(value.accuracyM));
|
||||||
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
setCapturedAt(value.capturedAt ? localDateTime(value.capturedAt) : '');
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ const interactiveLayers = ['assets-points', 'assets-lines', 'assets-polygons'];
|
|||||||
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
function boundsFromFeatures(collection: MapAssetFeatureCollection) {
|
||||||
const positions: Array<[number, number]> = [];
|
const positions: Array<[number, number]> = [];
|
||||||
collection.features.forEach((feature) => {
|
collection.features.forEach((feature) => {
|
||||||
if (feature.geometry.type === 'POINT') positions.push(feature.geometry.coordinates);
|
if (feature.geometry.type === 'Point') positions.push(feature.geometry.coordinates);
|
||||||
if (feature.geometry.type === 'LINESTRING') positions.push(...feature.geometry.coordinates);
|
if (feature.geometry.type === 'LineString') positions.push(...feature.geometry.coordinates);
|
||||||
if (feature.geometry.type === 'POLYGON') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
if (feature.geometry.type === 'Polygon') feature.geometry.coordinates.forEach((ring) => positions.push(...ring));
|
||||||
});
|
});
|
||||||
if (!positions.length) return null;
|
if (!positions.length) return null;
|
||||||
return positions.reduce<[number, number, number, number]>((result, point) => [
|
return positions.reduce<[number, number, number, number]>((result, point) => [
|
||||||
|
|||||||
+13
-4
@@ -536,9 +536,9 @@ export type Position = [number, number];
|
|||||||
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
export type AssetGeometryType = 'POINT' | 'LINESTRING' | 'POLYGON';
|
||||||
|
|
||||||
export type GeoJsonGeometry =
|
export type GeoJsonGeometry =
|
||||||
| { type: 'POINT'; coordinates: Position }
|
| { type: 'Point'; coordinates: Position }
|
||||||
| { type: 'LINESTRING'; coordinates: Position[] }
|
| { type: 'LineString'; coordinates: Position[] }
|
||||||
| { type: 'POLYGON'; coordinates: Position[][] };
|
| { type: 'Polygon'; coordinates: Position[][] };
|
||||||
|
|
||||||
export interface AssetGeometry {
|
export interface AssetGeometry {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
@@ -2162,6 +2162,15 @@ export async function getAssetGeometry(assetId: string) {
|
|||||||
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
|
return (await apiRequest<{ data: AssetGeometry | null }>(`/assets/${assetId}/geometry`)).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function geometryPayload(geometry: GeoJsonGeometry) {
|
||||||
|
const type: AssetGeometryType = geometry.type === 'Point'
|
||||||
|
? 'POINT'
|
||||||
|
: geometry.type === 'LineString'
|
||||||
|
? 'LINESTRING'
|
||||||
|
: 'POLYGON';
|
||||||
|
return { type, coordinates: geometry.coordinates };
|
||||||
|
}
|
||||||
|
|
||||||
export function upsertAssetGeometry(assetId: string, input: {
|
export function upsertAssetGeometry(assetId: string, input: {
|
||||||
geometry: GeoJsonGeometry;
|
geometry: GeoJsonGeometry;
|
||||||
accuracyM?: number | null;
|
accuracyM?: number | null;
|
||||||
@@ -2169,7 +2178,7 @@ export function upsertAssetGeometry(assetId: string, input: {
|
|||||||
deviceLabel?: string | null;
|
deviceLabel?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
return apiRequest<AssetGeometry>(`/assets/${assetId}/geometry`, {
|
||||||
method: 'PUT', body: JSON.stringify(input),
|
method: 'PUT', body: JSON.stringify({ ...input, geometry: geometryPayload(input.geometry) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export interface InspectionReportDetailF4 {
|
|||||||
companies: Array<{ id: string; code: string; name: string }>;
|
companies: Array<{ id: string; code: string; name: string }>;
|
||||||
areas: Array<{ id: string; code: string; name: string }>;
|
areas: Array<{ id: string; code: string; name: string }>;
|
||||||
findingCount: number;
|
findingCount: number;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
deadlineReason: string | null;
|
||||||
|
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: string }>;
|
||||||
|
findings: Array<{ id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null }>;
|
||||||
|
companyResponses: Array<{
|
||||||
|
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
|
||||||
|
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingInspectionReportF4 {
|
export interface PendingInspectionReportF4 {
|
||||||
@@ -176,6 +184,30 @@ export function officializeInspectionReport(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setInspectionReportResponseDeadline(
|
||||||
|
id: string,
|
||||||
|
input: { responseDueOn: string; reason?: string | null },
|
||||||
|
) {
|
||||||
|
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/response-deadline`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInspectionReportCompanyResponse(
|
||||||
|
id: string,
|
||||||
|
input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File | null },
|
||||||
|
) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('receivedOn', input.receivedOn);
|
||||||
|
if (input.details?.trim()) body.set('details', input.details.trim());
|
||||||
|
if (input.committedCorrectionOn) body.set('committedCorrectionOn', input.committedCorrectionOn);
|
||||||
|
if (input.contactName?.trim()) body.set('contactName', input.contactName.trim());
|
||||||
|
if (input.contactEmail?.trim()) body.set('contactEmail', input.contactEmail.trim());
|
||||||
|
if (input.file) body.set('file', input.file);
|
||||||
|
return apiRequest<InspectionReportWorkflowView>(`/inspection-reports/${id}/company-responses`, { method: 'POST', body });
|
||||||
|
}
|
||||||
|
|
||||||
export function addInspectionReportFollowUp(
|
export function addInspectionReportFollowUp(
|
||||||
id: string,
|
id: string,
|
||||||
input: {
|
input: {
|
||||||
@@ -206,6 +238,10 @@ export function inspectionReportGedoPdfDownloadUrl(id: string) {
|
|||||||
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
return `/api/v3/inspection-reports/${id}/gedo-pdf`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inspectionReportCompanyResponseDownloadUrl(reportId: string, responseId: string) {
|
||||||
|
return `/api/v3/inspection-reports/${reportId}/company-responses/${responseId}/content`;
|
||||||
|
}
|
||||||
|
|
||||||
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
|
export function inspectionReportFollowUpDownloadUrl(reportId: string, followUpId: string) {
|
||||||
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
|
return `/api/v3/inspection-reports/${reportId}/follow-ups/${followUpId}/content`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -2,21 +2,22 @@ import { useEffect, useState } from 'react';
|
|||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { Link, useParams } from 'react-router';
|
import { Link, useParams } from 'react-router';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { actCompanyResponseContentUrl, getActAdministration } from '../lib/api';
|
|
||||||
import type { ActAdministrationDetail } from '../lib/api';
|
|
||||||
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { SearchableSelect } from '../components/SearchableSelect';
|
import { SearchableSelect } from '../components/SearchableSelect';
|
||||||
import { formatDate } from '../lib/format';
|
import { formatDate, formatDateOnly } from '../lib/format';
|
||||||
import {
|
import {
|
||||||
|
addInspectionReportCompanyResponse,
|
||||||
addInspectionReportFollowUp,
|
addInspectionReportFollowUp,
|
||||||
getInspectionReportF4,
|
getInspectionReportF4,
|
||||||
inspectionReportGedoPdfDownloadUrl,
|
inspectionReportCompanyResponseDownloadUrl,
|
||||||
inspectionReportFollowUpDownloadUrl,
|
|
||||||
inspectionReportConsolidatedWordDownloadUrl,
|
inspectionReportConsolidatedWordDownloadUrl,
|
||||||
|
inspectionReportFollowUpDownloadUrl,
|
||||||
|
inspectionReportGedoPdfDownloadUrl,
|
||||||
inspectionReportWordDownloadUrl,
|
inspectionReportWordDownloadUrl,
|
||||||
listInspectionReportFollowUps,
|
listInspectionReportFollowUps,
|
||||||
officializeInspectionReport,
|
officializeInspectionReport,
|
||||||
|
setInspectionReportResponseDeadline,
|
||||||
updateInspectionReportNarrative,
|
updateInspectionReportNarrative,
|
||||||
} from '../lib/reportWorkflowApi';
|
} from '../lib/reportWorkflowApi';
|
||||||
import type {
|
import type {
|
||||||
@@ -34,6 +35,11 @@ function localDateTime(value: Date): string {
|
|||||||
return local.toISOString().slice(0, 16);
|
return local.toISOString().slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDate(value = new Date()): string {
|
||||||
|
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function reportStatusLabel(status: InspectionReportDetailF4['status']): string {
|
function reportStatusLabel(status: InspectionReportDetailF4['status']): string {
|
||||||
if (status === 'WORKING') return 'En preparación';
|
if (status === 'WORKING') return 'En preparación';
|
||||||
if (status === 'OFFICIALIZED') return 'Oficializado en GEDO';
|
if (status === 'OFFICIALIZED') return 'Oficializado en GEDO';
|
||||||
@@ -66,10 +72,8 @@ export function ReportDetailPage() {
|
|||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canManage = hasPermission('inspection_reports.generate');
|
const canManage = hasPermission('inspection_reports.generate');
|
||||||
const canReadActHistory = hasPermission('inspection_acts.read');
|
|
||||||
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
|
const [report, setReport] = useState<InspectionReportDetailF4 | null>(null);
|
||||||
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
|
const [followUps, setFollowUps] = useState<InspectionReportFollowUp[]>([]);
|
||||||
const [legacy, setLegacy] = useState<ActAdministrationDetail | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -77,12 +81,21 @@ export function ReportDetailPage() {
|
|||||||
|
|
||||||
const [executiveSummary, setExecutiveSummary] = useState('');
|
const [executiveSummary, setExecutiveSummary] = useState('');
|
||||||
const [reportDescription, setReportDescription] = useState('');
|
const [reportDescription, setReportDescription] = useState('');
|
||||||
|
|
||||||
const [gedoIfIdentifier, setGedoIfIdentifier] = useState('');
|
const [gedoIfIdentifier, setGedoIfIdentifier] = useState('');
|
||||||
const [gedoOfficializedAt, setGedoOfficializedAt] = useState(localDateTime(new Date()));
|
const [gedoOfficializedAt, setGedoOfficializedAt] = useState(localDateTime(new Date()));
|
||||||
const [gedoFile, setGedoFile] = useState<File | null>(null);
|
const [gedoFile, setGedoFile] = useState<File | null>(null);
|
||||||
|
|
||||||
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('COMPANY_NOTE');
|
const [responseDueOn, setResponseDueOn] = useState('');
|
||||||
|
const [deadlineReason, setDeadlineReason] = useState('');
|
||||||
|
|
||||||
|
const [companyReceivedOn, setCompanyReceivedOn] = useState(localDate());
|
||||||
|
const [companyDetails, setCompanyDetails] = useState('');
|
||||||
|
const [companyCommittedOn, setCompanyCommittedOn] = useState('');
|
||||||
|
const [companyContactName, setCompanyContactName] = useState('');
|
||||||
|
const [companyContactEmail, setCompanyContactEmail] = useState('');
|
||||||
|
const [companyResponseFile, setCompanyResponseFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const [followUpType, setFollowUpType] = useState<InspectionReportFollowUpType>('INTERNAL_NOTE');
|
||||||
const [followUpOccurredAt, setFollowUpOccurredAt] = useState(localDateTime(new Date()));
|
const [followUpOccurredAt, setFollowUpOccurredAt] = useState(localDateTime(new Date()));
|
||||||
const [followUpReference, setFollowUpReference] = useState('');
|
const [followUpReference, setFollowUpReference] = useState('');
|
||||||
const [followUpDescription, setFollowUpDescription] = useState('');
|
const [followUpDescription, setFollowUpDescription] = useState('');
|
||||||
@@ -96,73 +109,80 @@ export function ReportDetailPage() {
|
|||||||
]);
|
]);
|
||||||
setReport(nextReport);
|
setReport(nextReport);
|
||||||
setFollowUps(nextFollowUps);
|
setFollowUps(nextFollowUps);
|
||||||
if (canReadActHistory) setLegacy(await getActAdministration(nextReport.actId).catch(() => null));
|
|
||||||
setExecutiveSummary(nextReport.executiveSummary ?? '');
|
setExecutiveSummary(nextReport.executiveSummary ?? '');
|
||||||
setReportDescription(nextReport.reportDescription ?? '');
|
setReportDescription(nextReport.reportDescription ?? '');
|
||||||
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
|
setGedoIfIdentifier(nextReport.gedoIfIdentifier ?? '');
|
||||||
if (nextReport.gedoOfficializedAt) {
|
if (nextReport.gedoOfficializedAt) setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt)));
|
||||||
setGedoOfficializedAt(localDateTime(new Date(nextReport.gedoOfficializedAt)));
|
setResponseDueOn(nextReport.responseDueOn?.slice(0, 10) ?? '');
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
reload()
|
reload().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
|
||||||
.catch((requestError) => setError(errorMessage(requestError)))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const saveNarrative = async (event: FormEvent) => {
|
const saveNarrative = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!id || report?.status !== 'WORKING') return;
|
if (!id || report?.status !== 'WORKING') return;
|
||||||
setWorking(true);
|
setWorking(true); setError(''); setSuccess('');
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
try {
|
try {
|
||||||
await updateInspectionReportNarrative(id, {
|
await updateInspectionReportNarrative(id, { executiveSummary: executiveSummary.trim() || null, description: reportDescription.trim() || null });
|
||||||
executiveSummary: executiveSummary.trim() || null,
|
|
||||||
description: reportDescription.trim() || null,
|
|
||||||
});
|
|
||||||
await reload();
|
await reload();
|
||||||
setSuccess('Contenido editable del INF actualizado. El Acta fuente y sus Hallazgos no fueron modificados.');
|
setSuccess('Contenido editable del INF actualizado. El Acta fuente y sus Hallazgos no fueron modificados.');
|
||||||
} catch (requestError) {
|
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||||
setError(errorMessage(requestError));
|
|
||||||
} finally {
|
|
||||||
setWorking(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const officialize = async (event: FormEvent) => {
|
const officialize = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!id || !gedoFile || !gedoIfIdentifier.trim() || !gedoOfficializedAt) return;
|
if (!id || !gedoFile || !gedoIfIdentifier.trim() || !gedoOfficializedAt) return;
|
||||||
setWorking(true);
|
setWorking(true); setError(''); setSuccess('');
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
try {
|
try {
|
||||||
await officializeInspectionReport(id, {
|
await officializeInspectionReport(id, { gedoIfIdentifier: gedoIfIdentifier.trim(), gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(), file: gedoFile });
|
||||||
gedoIfIdentifier: gedoIfIdentifier.trim(),
|
|
||||||
gedoOfficializedAt: new Date(gedoOfficializedAt).toISOString(),
|
|
||||||
file: gedoFile,
|
|
||||||
});
|
|
||||||
setGedoFile(null);
|
setGedoFile(null);
|
||||||
await reload();
|
await reload();
|
||||||
setSuccess('IF oficial de GEDO registrado. El PDF y su hash quedaron fijados de forma inmutable.');
|
setSuccess('PDF oficial e identificador IF de GEDO cargados manualmente. El Informe quedó oficializado.');
|
||||||
} catch (requestError) {
|
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||||
setError(errorMessage(requestError));
|
};
|
||||||
} finally {
|
|
||||||
setWorking(false);
|
const saveDeadline = async (event: FormEvent) => {
|
||||||
}
|
event.preventDefault();
|
||||||
|
if (!id || report?.status !== 'OFFICIALIZED' || !responseDueOn) return;
|
||||||
|
setWorking(true); setError(''); setSuccess('');
|
||||||
|
try {
|
||||||
|
await setInspectionReportResponseDeadline(id, { responseDueOn, reason: deadlineReason.trim() || null });
|
||||||
|
setDeadlineReason('');
|
||||||
|
await reload();
|
||||||
|
setSuccess(`Vencimiento ${formatDateOnly(responseDueOn)} aplicado a todos los Hallazgos del Acta ${report.act.code}.`);
|
||||||
|
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCompanyResponse = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!id || report?.status !== 'OFFICIALIZED' || !companyReceivedOn) return;
|
||||||
|
if (!companyDetails.trim() && !companyResponseFile) return;
|
||||||
|
setWorking(true); setError(''); setSuccess('');
|
||||||
|
try {
|
||||||
|
await addInspectionReportCompanyResponse(id, {
|
||||||
|
receivedOn: companyReceivedOn,
|
||||||
|
details: companyDetails.trim() || undefined,
|
||||||
|
committedCorrectionOn: companyCommittedOn || undefined,
|
||||||
|
contactName: companyContactName.trim() || undefined,
|
||||||
|
contactEmail: companyContactEmail.trim() || undefined,
|
||||||
|
file: companyResponseFile,
|
||||||
|
});
|
||||||
|
setCompanyDetails(''); setCompanyCommittedOn(''); setCompanyContactName(''); setCompanyContactEmail(''); setCompanyResponseFile(null); setCompanyReceivedOn(localDate());
|
||||||
|
await reload();
|
||||||
|
setSuccess(`Respuesta de empresa registrada y vinculada al Informe ${report.code} y al Acta ${report.act.code}.`);
|
||||||
|
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const addFollowUp = async (event: FormEvent) => {
|
const addFollowUp = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!id || !followUpOccurredAt) return;
|
if (!id || !followUpOccurredAt) return;
|
||||||
if (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile) return;
|
if (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile) return;
|
||||||
setWorking(true);
|
setWorking(true); setError(''); setSuccess('');
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
try {
|
try {
|
||||||
const next = await addInspectionReportFollowUp(id, {
|
const next = await addInspectionReportFollowUp(id, {
|
||||||
type: followUpType,
|
type: followUpType,
|
||||||
@@ -171,17 +191,9 @@ export function ReportDetailPage() {
|
|||||||
description: followUpDescription.trim() || null,
|
description: followUpDescription.trim() || null,
|
||||||
file: followUpFile,
|
file: followUpFile,
|
||||||
});
|
});
|
||||||
setFollowUps(next);
|
setFollowUps(next); setFollowUpReference(''); setFollowUpDescription(''); setFollowUpFile(null); setFollowUpOccurredAt(localDateTime(new Date()));
|
||||||
setFollowUpReference('');
|
setSuccess('Antecedente agregado al historial del Informe.');
|
||||||
setFollowUpDescription('');
|
} catch (requestError) { setError(errorMessage(requestError)); } finally { setWorking(false); }
|
||||||
setFollowUpFile(null);
|
|
||||||
setFollowUpOccurredAt(localDateTime(new Date()));
|
|
||||||
setSuccess('Antecedente agregado al seguimiento del INF. Los registros anteriores permanecen intactos.');
|
|
||||||
} catch (requestError) {
|
|
||||||
setError(errorMessage(requestError));
|
|
||||||
} finally {
|
|
||||||
setWorking(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
if (loading) return <LoadingBlock label="Cargando informe…" />;
|
||||||
@@ -190,105 +202,65 @@ export function ReportDetailPage() {
|
|||||||
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
|
{ id: 'act-start', date: report.act.occurredAt, title: `Inspección y Acta ${report.act.code}`, description: `${report.findingCount} hallazgo${report.findingCount === 1 ? '' : 's'} registrados`, href: `/inspecciones/actas/${report.actId}` },
|
||||||
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
|
...(report.act.sealedAt ? [{ id: 'act-sealed', date: report.act.sealedAt, title: 'Acta firmada y cerrada', description: report.act.code, href: `/inspecciones/actas/${report.actId}` }] : []),
|
||||||
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
|
{ id: 'report-issued', date: report.generatedAt, title: `Informe ${report.code} preparado`, description: 'Documento técnico vinculado al Acta' },
|
||||||
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'Informe oficializado en GEDO', description: report.gedoIfIdentifier ?? '' }] : []),
|
...(report.gedoOfficializedAt ? [{ id: 'gedo', date: report.gedoOfficializedAt, title: 'PDF oficial de GEDO cargado', description: report.gedoIfIdentifier ?? '' }] : []),
|
||||||
|
...report.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Vencimiento general del Acta', description: `${formatDateOnly(item.responseDueOn)} · ${item.reason}` })),
|
||||||
|
...report.companyResponses.map((item) => ({ id: `response-${item.id}`, date: `${item.receivedOn}T12:00:00`, title: `Respuesta de empresa · Acta ${report.act.code}`, description: item.details ?? item.originalName ?? 'Respuesta registrada', href: item.originalName ? inspectionReportCompanyResponseDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||||
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
...followUps.map((item) => ({ id: item.id, date: item.occurredAt, title: followUpLabel(item.type), description: item.description || item.externalReference || item.originalName || 'Antecedente registrado', href: item.originalName ? inspectionReportFollowUpDownloadUrl(report.id, item.id) : undefined, fileName: item.originalName ?? undefined })),
|
||||||
...(legacy?.responses.map((item) => ({ id: `legacy-${item.id}`, date: item.receivedOn, title: 'Respuesta de empresa registrada previamente', description: item.details ?? 'Sin detalle', href: item.originalName ? actCompanyResponseContentUrl(item.id) : undefined, fileName: item.originalName ?? undefined })) ?? []),
|
|
||||||
...(legacy?.deadlines.map((item) => ({ id: `deadline-${item.id}`, date: item.createdAt, title: 'Plazo administrativo registrado previamente', description: `${formatDate(item.responseDueOn)} · ${item.reason}` })) ?? []),
|
|
||||||
].sort((a, b) => b.date.localeCompare(a.date)) : [];
|
].sort((a, b) => b.date.localeCompare(a.date)) : [];
|
||||||
|
|
||||||
return <section>
|
return <section>
|
||||||
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
<div className="breadcrumb"><Link to="/informes">Informes</Link><span>/</span><span>{report?.code ?? 'Informe'}</span></div>
|
||||||
<div className="page-heading survey-editor-heading">
|
<div className="page-heading survey-editor-heading">
|
||||||
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.code ?? 'Informe'}</h1><p>{report ? `Acta ${report.act.code} · generado ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
|
<div><span className="eyebrow">INFORME DE INSPECCIÓN</span><h1>{report?.code ?? 'Informe'}</h1><p>{report ? `Acta ${report.act.code} · generado ${formatDate(report.generatedAt)}` : 'Consulta del informe.'}</p></div>
|
||||||
{report && <div className="report-status-stack">
|
{report && <div className="report-status-stack"><span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span><span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span></div>}
|
||||||
<span className={`status-badge large ${report.wordStatus === 'READY' ? 'active' : report.wordStatus === 'FAILED' ? 'danger' : 'pending'}`}>{report.wordStatus === 'READY' ? 'Word disponible' : report.wordStatus === 'FAILED' ? 'Word con error' : 'Word pendiente'}</span>
|
|
||||||
<span className={`status-badge large ${reportStatusClass(report.status)}`}>{reportStatusLabel(report.status)}</span>
|
|
||||||
</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
{success && <Alert type="success">{success}</Alert>}
|
{success && <Alert type="success">{success}</Alert>}
|
||||||
|
|
||||||
{report && <>
|
{report && <>
|
||||||
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div>
|
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un Informe corresponde a una sola Acta.</strong> GEDO no se consulta automáticamente: la oficialización se registra manualmente cargando el IF y su PDF oficial. Esa carga no genera una respuesta de empresa ni define un vencimiento por sí sola.</p></div>
|
||||||
|
|
||||||
<section className="panel report-summary-panel">
|
<section className="panel report-summary-panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
|
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
|
||||||
<div className="responsible-summary">
|
<div className="responsible-summary"><div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div><div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div><div><small>Hallazgos</small><strong>{report.findingCount}</strong></div><div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div></div>
|
||||||
<div><small>Empresa</small><strong>{names(report.companies, 'Sin asignar')}</strong></div>
|
<div className="report-linked-documents"><Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link><Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link><Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link></div>
|
||||||
<div><small>Área / Yacimiento</small><strong>{names(report.areas, 'Sin asignar')}</strong></div>
|
|
||||||
<div><small>Hallazgos</small><strong>{report.findingCount}</strong></div>
|
|
||||||
<div><small>Generado por</small><strong>{report.generatedBy.firstName} {report.generatedBy.lastName}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div className="report-linked-documents">
|
|
||||||
<Link to={`/inspecciones/${report.visitId}`}><span>Inspección</span><strong>{report.visit.code}</strong><Icon name="chevron" /></Link>
|
|
||||||
<Link to={`/inspecciones/actas/${report.actId}`}><span>Acta fuente</span><strong>{report.act.code}</strong><small>Contenido inmutable</small><Icon name="chevron" /></Link>
|
|
||||||
<Link to={`/hallazgos?search=${encodeURIComponent(report.act.code)}`}><span>Hallazgos del Acta</span><strong>{report.findingCount}</strong><small>Seguimiento técnico</small><Icon name="chevron" /></Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
|
||||||
<form className="form-section" onSubmit={saveNarrative}>
|
|
||||||
<label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Resumen ejecutivo del Informe…" /></label>
|
|
||||||
<label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} placeholder="Descripción técnica, análisis y consideraciones del Inspector…" /></label>
|
|
||||||
{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}
|
|
||||||
{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar el IF oficial de GEDO.</Alert>}
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">Cuando GEDO devuelve el IF y el PDF oficial, ambos se registran en el sistema y pasan a ser la referencia documental institucional.</p></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto antes de enviarlo a GEDO. Esta edición no altera el Acta fuente.</p></div><div className="act-primary-actions"><a className="button secondary" href={inspectionReportConsolidatedWordDownloadUrl(report.id)}>Descargar Word del informe</a>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Word anterior</a>}</div></div>
|
||||||
{report.status === 'OFFICIALIZED' ? <>
|
<form className="form-section" onSubmit={saveNarrative}><label className="field"><span>Resumen ejecutivo</span><textarea rows={4} maxLength={20000} value={executiveSummary} onChange={(event) => setExecutiveSummary(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} /></label><label className="field"><span>Descripción / análisis técnico</span><textarea rows={8} maxLength={50000} value={reportDescription} onChange={(event) => setReportDescription(event.target.value)} disabled={!canManage || report.status !== 'WORKING'} /></label>{canManage && report.status === 'WORKING' && <div className="form-actions"><button className="button primary" disabled={working}>{working ? 'Guardando…' : 'Guardar contenido del INF'}</button></div>}{report.status !== 'WORKING' && <Alert type="info">El contenido editable se cerró al registrar manualmente el PDF oficial de GEDO.</Alert>}</form>
|
||||||
<div className="responsible-summary">
|
|
||||||
<div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div>
|
|
||||||
<div><small>Oficializado</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div>
|
|
||||||
<div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div>
|
|
||||||
<div><small>Vencimiento del Acta</small><strong>{formatDate(report.act.deadlineAt)}</strong></div>
|
|
||||||
</div>
|
|
||||||
{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}
|
|
||||||
{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}
|
|
||||||
</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}>
|
|
||||||
<div className="form-grid">
|
|
||||||
<label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label>
|
|
||||||
<label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label>
|
|
||||||
</div>
|
|
||||||
<label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label>
|
|
||||||
<Alert>Esta acción cierra la edición del INF. El IF, la fecha y el hash del PDF oficial quedarán registrados como trazabilidad institucional.</Alert>
|
|
||||||
<div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Registrar IF y PDF oficial'}</button></div>
|
|
||||||
</form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.</Alert>}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">INFORME Y RESPUESTAS</span><h2>Historia y presentaciones</h2><p className="section-copy">La historia del Acta y las respuestas posteriores se leen en orden. Las nuevas respuestas se registran en este Informe.</p></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">GEDO</span><h2>Oficialización del Informe</h2><p className="section-copy">No existe una respuesta automática de GEDO. Cuando recibas el identificador IF y el PDF oficial, cargalos manualmente aquí.</p></div></div>
|
||||||
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}>
|
{report.status === 'OFFICIALIZED' ? <><div className="responsible-summary"><div><small>Identificador IF</small><strong>{report.gedoIfIdentifier ?? '—'}</strong></div><div><small>Fecha GEDO</small><strong>{formatDate(report.gedoOfficializedAt)}</strong></div><div><small>PDF oficial</small><strong>{report.gedoPdfOriginalName ?? 'Registrado'}</strong></div><div><small>Vencimiento de respuestas</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div></div>{report.gedoPdfOriginalName && <div className="form-actions"><a className="button secondary" href={inspectionReportGedoPdfDownloadUrl(report.id)}>Descargar PDF oficial</a></div>}{report.gedoPdfSha256 && <div className="temporal-notice"><Icon name="check" /><p><strong>PDF GEDO fijado.</strong> SHA-256: <code>{report.gedoPdfSha256}</code></p></div>}</> : canManage && report.status === 'WORKING' ? <form className="form-section" onSubmit={officialize}><div className="form-grid"><label className="field"><span>Identificador IF de GEDO</span><input value={gedoIfIdentifier} onChange={(event) => setGedoIfIdentifier(event.target.value)} required maxLength={255} placeholder="IF-2026-…" /></label><label className="field"><span>Fecha de oficialización</span><input type="datetime-local" value={gedoOfficializedAt} onChange={(event) => setGedoOfficializedAt(event.target.value)} required /></label></div><label className="field"><span>PDF oficial de GEDO</span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setGedoFile(event.target.files?.[0] ?? null)} required /></label><Alert>Esta carga es manual. Registra la referencia institucional del Informe, pero no crea respuestas ni vencimientos automáticamente.</Alert><div className="form-actions"><button className="button primary" disabled={working || !gedoFile || !gedoIfIdentifier.trim()}>{working ? 'Registrando…' : 'Cargar IF y PDF oficial'}</button></div></form> : <Alert type="info">El Informe todavía no está oficializado en GEDO.{report.status === 'WORKING' && !canManage ? ' Tu usuario no tiene permiso para gestionar u oficializar Informes.' : ''}</Alert>}
|
||||||
<div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div>
|
|
||||||
<span>{formatDate(item.date)}</span>
|
|
||||||
</div>)}</div>
|
|
||||||
|
|
||||||
{canManage && <form className="form-section" onSubmit={addFollowUp}>
|
|
||||||
<div><h3>Registrar respuesta o antecedente</h3><p className="section-copy">La respuesta queda asociada a este Informe y conserva los registros anteriores.</p></div>
|
|
||||||
<div className="form-grid">
|
|
||||||
<label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="COMPANY_NOTE">Presentación / nota de empresa</option><option value="COMPANY_DOCUMENT">Documento de empresa</option><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label>
|
|
||||||
<label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label>
|
|
||||||
<label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} placeholder="GEDO, expediente, nota, ticket…" /></label>
|
|
||||||
</div>
|
|
||||||
<label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} placeholder="Contenido o resumen de la presentación…" /></label>
|
|
||||||
<label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label>
|
|
||||||
<div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>{working ? 'Agregando…' : 'Agregar al historial'}</button></div>
|
|
||||||
</form>}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel report-integrity-panel">
|
<section className="panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">PLAZO DE RESPUESTA</span><h2>Vencimiento común del Acta</h2><p className="section-copy">La fecha se define una sola vez para el Acta {report.act.code} y se proyecta sobre todos sus Hallazgos.</p></div></div>
|
||||||
<dl className="report-integrity-list">
|
{report.status !== 'OFFICIALIZED' ? <Alert type="info">Este paso se habilita después de cargar el PDF oficial de GEDO.</Alert> : <>
|
||||||
<div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div>
|
<div className="responsible-summary"><div><small>Acta relacionada</small><strong>{report.act.code}</strong></div><div><small>Vencimiento vigente</small><strong>{formatDateOnly(report.responseDueOn)}</strong></div><div><small>Hallazgos alcanzados</small><strong>{report.findings.length}</strong></div><div><small>Motivo / referencia</small><strong>{report.deadlineReason ?? 'Sin definir'}</strong></div></div>
|
||||||
<div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div>
|
{canManage && <form className="form-section" onSubmit={saveDeadline}><div className="form-grid"><label className="field"><span>Fecha de vencimiento</span><input type="date" value={responseDueOn} onChange={(event) => setResponseDueOn(event.target.value)} required /></label><label className="field"><span>Motivo / referencia <em>opcional</em></span><input value={deadlineReason} onChange={(event) => setDeadlineReason(event.target.value)} maxLength={1000} placeholder={`Vencimiento general del Acta ${report.act.code}`} /></label></div><div className="form-actions"><button className="button primary" disabled={working || !responseDueOn}>{report.responseDueOn ? 'Registrar nuevo vencimiento' : 'Definir vencimiento'}</button></div></form>}
|
||||||
<div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div>
|
</>}
|
||||||
<div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div>
|
<div className="table-scroll"><table><thead><tr><th>Hallazgo</th><th>Elemento</th><th>Estado</th><th>Vencimiento</th></tr></thead><tbody>{report.findings.map((finding) => <tr key={finding.id}><td><Link className="text-link" to={`/hallazgos/${finding.id}`}>{finding.code}</Link><small className="block-muted">{finding.title}</small></td><td><strong>{finding.assetName}</strong><small className="block-muted">{finding.assetCode}</small></td><td><span className={`status-badge ${finding.status === 'OPEN' ? 'observed' : 'active'}`}>{finding.status === 'OPEN' ? 'Abierto' : finding.status}</span></td><td><strong>{formatDateOnly(finding.responseDueOn)}</strong></td></tr>)}</tbody></table></div>
|
||||||
</dl>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">RESPUESTAS DE EMPRESA</span><h2>Presentaciones vinculadas al Acta {report.act.code}</h2><p className="section-copy">Cada respuesta queda vinculada simultáneamente a este Informe y a su Acta fuente. Puede incluir un PDF recibido de la empresa.</p></div></div>
|
||||||
|
{report.status !== 'OFFICIALIZED' ? <Alert type="info">Las respuestas se habilitan después de cargar el PDF oficial de GEDO.</Alert> : <>
|
||||||
|
{report.companyResponses.length === 0 ? <Alert type="info">Todavía no se registraron respuestas de la empresa.</Alert> : <div className="dossier-link-list">{report.companyResponses.map((item) => <div key={item.id}><div><strong>Respuesta recibida {formatDateOnly(item.receivedOn)}</strong><small>{item.details ?? 'Sin detalle'}{item.committedCorrectionOn ? ` · Compromiso: ${formatDateOnly(item.committedCorrectionOn)}` : ''}</small>{item.contactName && <small>{item.contactName}{item.contactEmail ? ` · ${item.contactEmail}` : ''}</small>}{item.originalName && <a className="text-link" href={inspectionReportCompanyResponseDownloadUrl(report.id, item.id)}>Descargar {item.originalName} {fileSize(item.sizeBytes) && `· ${fileSize(item.sizeBytes)}`}</a>}</div><span>Acta {report.act.code}</span></div>)}</div>}
|
||||||
|
{canManage && <form className="form-section" onSubmit={addCompanyResponse}><div><h3>Registrar respuesta</h3><p className="section-copy">La respuesta se registra sobre el Informe {report.code} y queda relacionada con el Acta {report.act.code}.</p></div><div className="form-grid"><label className="field"><span>Fecha de recepción</span><input type="date" value={companyReceivedOn} onChange={(event) => setCompanyReceivedOn(event.target.value)} required /></label><label className="field"><span>Fecha comprometida por la empresa <em>opcional</em></span><input type="date" value={companyCommittedOn} onChange={(event) => setCompanyCommittedOn(event.target.value)} /></label><label className="field"><span>Contacto <em>opcional</em></span><input value={companyContactName} onChange={(event) => setCompanyContactName(event.target.value)} maxLength={200} /></label><label className="field"><span>Email <em>opcional</em></span><input type="email" value={companyContactEmail} onChange={(event) => setCompanyContactEmail(event.target.value)} maxLength={320} /></label></div><label className="field"><span>Detalle de la respuesta</span><textarea rows={5} maxLength={8000} value={companyDetails} onChange={(event) => setCompanyDetails(event.target.value)} placeholder="Respuesta, descargo, compromiso o documentación presentada…" /></label><label className="field"><span>PDF de respuesta <em>opcional</em></span><input type="file" accept="application/pdf,.pdf" onChange={(event) => setCompanyResponseFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!companyDetails.trim() && !companyResponseFile)}>Registrar respuesta</button></div></form>}
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel-heading"><div><span className="eyebrow">HISTORIA DEL INFORME</span><h2>Otros antecedentes</h2><p className="section-copy">Cronología documental completa. Las respuestas formales de empresa se cargan en el bloque anterior; aquí quedan notas internas, verificaciones y otros antecedentes.</p></div></div>
|
||||||
|
<div className="dossier-link-list">{timeline.map((item) => <div key={item.id}><div><strong>{item.title}</strong><small>{item.description}</small>{item.href && (item.fileName ? <a className="text-link" href={item.href}>Descargar {item.fileName}</a> : <Link className="text-link" to={item.href}>Ver Acta</Link>)}</div><span>{formatDate(item.date)}</span></div>)}</div>
|
||||||
|
{canManage && <form className="form-section" onSubmit={addFollowUp}><div><h3>Agregar otro antecedente</h3><p className="section-copy">No usar este bloque para respuestas formales de empresa.</p></div><div className="form-grid"><label className="field"><span>Tipo</span><SearchableSelect value={followUpType} onChange={(event) => setFollowUpType(event.target.value as InspectionReportFollowUpType)}><option value="INTERNAL_NOTE">Nota interna</option><option value="VERIFICATION">Verificación</option><option value="OTHER">Otro antecedente</option></SearchableSelect></label><label className="field"><span>Fecha</span><input type="datetime-local" value={followUpOccurredAt} onChange={(event) => setFollowUpOccurredAt(event.target.value)} required /></label><label className="field"><span>Referencia externa <em>opcional</em></span><input value={followUpReference} onChange={(event) => setFollowUpReference(event.target.value)} maxLength={255} /></label></div><label className="field"><span>Descripción</span><textarea rows={4} maxLength={20000} value={followUpDescription} onChange={(event) => setFollowUpDescription(event.target.value)} /></label><label className="field"><span>Archivo <em>opcional</em></span><input type="file" onChange={(event) => setFollowUpFile(event.target.files?.[0] ?? null)} /></label><div className="form-actions"><button className="button primary" disabled={working || (!followUpDescription.trim() && !followUpReference.trim() && !followUpFile)}>Agregar al historial</button></div></form>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel report-integrity-panel"><div className="panel-heading"><div><span className="eyebrow">INTEGRIDAD</span><h2>Fuente inmutable</h2><p className="section-copy">El INF conserva una copia verificable del Acta sellada que le dio origen.</p></div></div><dl className="report-integrity-list"><div><dt>Acta fuente</dt><dd>{report.act.code}</dd></div><div><dt>Hash de cierre del Acta</dt><dd>{report.actClosureSha256}</dd></div><div><dt>Hash de la fuente del INF</dt><dd>{report.frozenSha256}</dd></div><div><dt>Estado del INF</dt><dd>{reportStatusLabel(report.status)}</dd></div></dl></section>
|
||||||
</>}
|
</>}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user