From cd24736df80cb7f63a55e5cab206e0e2b037bae3 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 14:46:26 -0300 Subject: [PATCH] =?UTF-8?q?F1.1=20=C2=B7=20Inspecci=C3=B3n=20multi-Acta=20?= =?UTF-8?q?y=20firma=20diferida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia. --- api-v3/package.json | 2 +- api-v3/src/database/entities/index.ts | 1 + .../inspection-act-signature.entity.ts | 11 ++ .../entities/inspection-act.entity.ts | 2 +- .../entities/inspection-report.entity.ts | 2 +- ...200000-phase-f1-1-multi-act-inspections.ts | 108 ++++++++++++++++++ .../inspection-acts.service.ts | 10 +- .../dto/create-company-signature.dto.ts | 19 +++ .../inspection-closing.controller.ts | 3 +- .../inspection-closing.service.ts | 72 +++++++++--- ...field-discovery-inspection-link.service.ts | 10 +- .../inspection-act-pdf-builder.ts | 11 ++ .../dto/close-inspection-visit.dto.ts | 6 + .../inspection-visits.controller.ts | 12 ++ .../inspection-visits.service.ts | 102 +++++++++++++++++ api-v3/src/version.ts | 4 +- .../unit/inspection-acts-single-act.test.ts | 30 ++--- .../inspection-visits-authorization.test.ts | 1 + .../unit/mobile-inspector-integration.test.ts | 2 +- ...5-3-19-inventory-filters-deadlines.test.ts | 6 +- ...hase-d5-3-20-automatic-word-report.test.ts | 2 +- .../phase-d5-3-21-document-delivery.test.ts | 2 +- ...ase-d5-3-22-director-report-review.test.ts | 4 +- .../phase-d5-3-23-field-discoveries.test.ts | 6 +- .../unit/phase-d5-6-phase-d-closure.test.ts | 10 +- ...ase-f1-1-multi-act-operating-model.test.ts | 71 ++++++++++++ web-v2/package.json | 2 +- web-v2/src/config/version.ts | 4 +- .../inspections/InspectionActsPanel.tsx | 8 +- .../inspections/InspectionClosurePanel.tsx | 6 +- web-v2/src/lib/api.ts | 2 + web-v2/src/lib/format.ts | 2 +- .../src/pages/InspectionVisitEditorPage.tsx | 2 +- web-v2/src/pages/InspectionVisitsPage.tsx | 2 +- 34 files changed, 452 insertions(+), 85 deletions(-) create mode 100644 api-v3/src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts create mode 100644 api-v3/src/inspection-closing/dto/create-company-signature.dto.ts create mode 100644 api-v3/src/inspection-visits/dto/close-inspection-visit.dto.ts create mode 100644 api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts diff --git a/api-v3/package.json b/api-v3/package.json index 829b100..8fc9ac3 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.19.6-4", + "version": "0.20.0-1", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/database/entities/index.ts b/api-v3/src/database/entities/index.ts index 1702629..4b9d2e4 100644 --- a/api-v3/src/database/entities/index.ts +++ b/api-v3/src/database/entities/index.ts @@ -93,6 +93,7 @@ export { InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, + InspectionCompanySignatureManifestation, } from './inspection-act-signature.entity'; export { FindingCategory } from './finding-category.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity'; diff --git a/api-v3/src/database/entities/inspection-act-signature.entity.ts b/api-v3/src/database/entities/inspection-act-signature.entity.ts index f78fd90..5ec250b 100644 --- a/api-v3/src/database/entities/inspection-act-signature.entity.ts +++ b/api-v3/src/database/entities/inspection-act-signature.entity.ts @@ -17,6 +17,11 @@ export enum InspectionActSignatureSource { ANDROID = 'ANDROID', } +export enum InspectionCompanySignatureManifestation { + CONFORMITY = 'CONFORMITY', + DISSENT = 'DISSENT', +} + @Entity({ name: 'inspection_act_signatures' }) @Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt']) @Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256']) @@ -51,6 +56,12 @@ export class InspectionActSignature { @Column({ type: 'text', nullable: true }) reason!: string | null; + @Column({ name: 'company_manifestation', type: 'varchar', length: 20, nullable: true }) + companyManifestation!: InspectionCompanySignatureManifestation | null; + + @Column({ name: 'company_statement', type: 'text', nullable: true }) + companyStatement!: string | null; + @Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true }) originalName!: string | null; diff --git a/api-v3/src/database/entities/inspection-act.entity.ts b/api-v3/src/database/entities/inspection-act.entity.ts index 2b4f4d0..d7e57be 100644 --- a/api-v3/src/database/entities/inspection-act.entity.ts +++ b/api-v3/src/database/entities/inspection-act.entity.ts @@ -10,7 +10,7 @@ export enum InspectionActStatus { } @Entity({ name: 'inspection_acts' }) -@Index('uq_inspection_acts_visit', ['visitId'], { unique: true }) +@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_code', ['code'], { unique: true }) @Index('idx_inspection_acts_visit_status', ['visitId', 'status']) diff --git a/api-v3/src/database/entities/inspection-report.entity.ts b/api-v3/src/database/entities/inspection-report.entity.ts index 0a146ed..77c4291 100644 --- a/api-v3/src/database/entities/inspection-report.entity.ts +++ b/api-v3/src/database/entities/inspection-report.entity.ts @@ -25,7 +25,7 @@ export enum InspectionReportReviewStatus { } @Entity({ name: 'inspection_reports' }) -@Index('uq_inspection_reports_visit', ['visitId'], { unique: true }) +@Index('idx_inspection_reports_visit_id', ['visitId']) @Index('uq_inspection_reports_act', ['actId'], { unique: true }) @Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true }) @Index('uq_inspection_reports_code', ['code'], { unique: true }) diff --git a/api-v3/src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts b/api-v3/src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts new file mode 100644 index 0000000..675018e --- /dev/null +++ b/api-v3/src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts @@ -0,0 +1,108 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseF11MultiActInspections1789495200000 implements MigrationInterface { + name = 'PhaseF11MultiActInspections1789495200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_acts + DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit + ON inspection_acts (visit_id) + WHERE status = 'DRAFT' + `); + + await queryRunner.query(` + ALTER TABLE inspection_reports + DROP CONSTRAINT IF EXISTS uq_inspection_reports_visit + `); + await queryRunner.query(` + CREATE INDEX idx_inspection_reports_visit_id + ON inspection_reports (visit_id) + `); + + await queryRunner.query(` + ALTER TABLE inspection_act_signatures + ADD COLUMN company_manifestation varchar(20), + ADD COLUMN company_statement text + `); + await queryRunner.query(` + UPDATE inspection_act_signatures + SET company_manifestation = 'CONFORMITY' + WHERE signer_type = 'COMPANY_RESPONSIBLE' + AND status = 'SIGNED' + `); + await queryRunner.query(` + ALTER TABLE inspection_act_signatures + ADD CONSTRAINT chk_inspection_act_signatures_company_manifestation CHECK ( + ( + signer_type = 'COMPANY_RESPONSIBLE' + AND status = 'SIGNED' + AND company_manifestation IN ('CONFORMITY', 'DISSENT') + AND ( + (company_manifestation = 'CONFORMITY' AND company_statement IS NULL) + OR + (company_manifestation = 'DISSENT' AND LENGTH(TRIM(COALESCE(company_statement, ''))) >= 10) + ) + ) + OR + ( + NOT (signer_type = 'COMPANY_RESPONSIBLE' AND status = 'SIGNED') + AND company_manifestation IS NULL + AND company_statement IS NULL + ) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT visit_id FROM inspection_acts + GROUP BY visit_id HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples actas'; + END IF; + IF EXISTS ( + SELECT visit_id FROM inspection_reports + GROUP BY visit_id HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples informes'; + END IF; + IF EXISTS ( + SELECT 1 FROM inspection_act_signatures + WHERE company_manifestation = 'DISSENT' + ) THEN + RAISE EXCEPTION 'F1.1 no puede revertirse: existen firmas en disidencia'; + END IF; + END + $$ + `); + + await queryRunner.query(` + ALTER TABLE inspection_act_signatures + DROP CONSTRAINT chk_inspection_act_signatures_company_manifestation + `); + await queryRunner.query(` + ALTER TABLE inspection_act_signatures + DROP COLUMN company_statement, + DROP COLUMN company_manifestation + `); + + await queryRunner.query(`DROP INDEX idx_inspection_reports_visit_id`); + await queryRunner.query(` + ALTER TABLE inspection_reports + ADD CONSTRAINT uq_inspection_reports_visit UNIQUE (visit_id) + `); + + await queryRunner.query(`DROP INDEX uq_inspection_acts_one_draft_per_visit`); + await queryRunner.query(` + ALTER TABLE inspection_acts + ADD CONSTRAINT uq_inspection_acts_visit UNIQUE (visit_id) + `); + } +} diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index d089c25..5d89a31 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -308,7 +308,7 @@ export class InspectionActsService { const visit = await this.lockVisit(manager, visitId); this.assertVisitOpen(visit); await this.assertActorAssigned(manager, visitId, principal); - await this.assertVisitHasNoAct(manager, visitId); + await this.assertVisitHasNoDraftAct(manager, visitId); await this.assertVisitAssets(manager, visitId, dto.assetIds); const occurredAt = new Date(dto.occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt); @@ -632,20 +632,20 @@ export class InspectionActsService { } } - private async assertVisitHasNoAct( + private async assertVisitHasNoDraftAct( manager: EntityManager, visitId: string, ): Promise { const rows = (await manager.query(` SELECT code FROM inspection_acts - WHERE visit_id = $1 + WHERE visit_id = $1 AND status = 'DRAFT' LIMIT 1 `, [visitId])) as Array<{ code: string }>; if (rows.length > 0) { throw new ConflictException({ - code: 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', - message: 'La visita ya tiene un acta; cada visita admite una única acta', + code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', + message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra', }); } } diff --git a/api-v3/src/inspection-closing/dto/create-company-signature.dto.ts b/api-v3/src/inspection-closing/dto/create-company-signature.dto.ts new file mode 100644 index 0000000..b612808 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/create-company-signature.dto.ts @@ -0,0 +1,19 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator'; +import { InspectionCompanySignatureManifestation } from '../../database/entities'; +import { CreateInspectionSignatureDto } from './create-inspection-signature.dto'; + +export class CreateCompanySignatureDto extends CreateInspectionSignatureDto { + @IsOptional() + @IsEnum(InspectionCompanySignatureManifestation) + manifestation?: InspectionCompanySignatureManifestation; + + @ValidateIf((dto: CreateCompanySignatureDto) => + dto.manifestation === InspectionCompanySignatureManifestation.DISSENT, + ) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(10) + @MaxLength(4000) + statement?: string; +} diff --git a/api-v3/src/inspection-closing/inspection-closing.controller.ts b/api-v3/src/inspection-closing/inspection-closing.controller.ts index fc3ec38..14e0663 100644 --- a/api-v3/src/inspection-closing/inspection-closing.controller.ts +++ b/api-v3/src/inspection-closing/inspection-closing.controller.ts @@ -19,6 +19,7 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; +import { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { InspectionClosingService } from './inspection-closing.service'; import { @@ -89,7 +90,7 @@ export class InspectionClosingController { })) companySignature( @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, - @Body() dto: CreateInspectionSignatureDto, + @Body() dto: CreateCompanySignatureDto, @UploadedFile() file: UploadedInspectionSignatureFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index 8954a10..be3c131 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -21,6 +21,7 @@ import { InspectionActSignatureSource, InspectionActSignatureStatus, InspectionActSignerType, + InspectionCompanySignatureManifestation, InspectionActStatus, InspectionActUploadMode, InspectionActVersionEvent, @@ -33,6 +34,7 @@ import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; +import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { inspectInspectionSignatureFile, @@ -70,6 +72,8 @@ interface SignatureView { position: string | null; status: InspectionActSignatureStatus; reason: string | null; + companyManifestation: InspectionCompanySignatureManifestation | null; + companyStatement: string | null; mimeType: string | null; sizeBytes: number | null; imageSha256: string | null; @@ -429,7 +433,7 @@ export class InspectionClosingService { async signCompany( actId: string, - dto: CreateInspectionSignatureDto, + dto: CreateCompanySignatureDto, file: UploadedInspectionSignatureFile | undefined, principal: AuthPrincipal, request: RequestWithContext, @@ -447,7 +451,7 @@ export class InspectionClosingService { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyInProgress(act, visit); + this.assertReadyForCompanyOutcome(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); const responsible = await this.requireResponsible(manager, actId); if ( @@ -540,7 +544,8 @@ export class InspectionClosingService { assertMobileInspector(principal); const closed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyInProgress(act, visit); + this.assertReadyForFinalClosure(act, visit); + await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const signatures = await this.loadSignatures(manager, actId); if (!signatures.some((item) => ( @@ -576,6 +581,8 @@ export class InspectionClosingService { position: item.position, status: item.status, reason: item.reason, + companyManifestation: item.companyManifestation, + companyStatement: item.companyStatement, imageSha256: item.imageSha256, consentText: item.consentText, consentVersion: item.consentVersion, @@ -636,14 +643,6 @@ export class InspectionClosingService { WHERE id = $1 RETURNING current_version AS "versionNumber" `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; - await manager.query(` - UPDATE inspection_visits - SET status = 'CLOSED', - actual_closed_at = $2, - updated_by = $3, - updated_at = $2 - WHERE id = $1 - `, [visit.id, serverClosedAt, principal.userId]); await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username @@ -672,6 +671,7 @@ export class InspectionClosingService { visitId: visit.id, versionNumber: Number(updated.versionNumber), findingsRemainOpen: true, + visitRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); @@ -710,7 +710,7 @@ export class InspectionClosingService { private async createSignedSignature( actId: string, - dto: CreateInspectionSignatureDto, + dto: CreateInspectionSignatureDto | CreateCompanySignatureDto, file: UploadedInspectionSignatureFile | undefined, fixedIdentity: null | { signerType: InspectionActSignerType; @@ -737,7 +737,8 @@ export class InspectionClosingService { try { return await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyInProgress(act, visit); + if (fixedIdentity) this.assertReadyInProgress(act, visit); + else this.assertReadyForCompanyOutcome(act, visit); await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); const closure = await this.requireClosure(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); @@ -778,6 +779,13 @@ export class InspectionClosingService { const source = this.signatureSource(principal); const signedAt = new Date(); const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; + const companyDto = fixedIdentity ? null : dto as CreateCompanySignatureDto; + const companyManifestation = fixedIdentity + ? null + : companyDto?.manifestation ?? InspectionCompanySignatureManifestation.CONFORMITY; + const companyStatement = companyManifestation === InspectionCompanySignatureManifestation.DISSENT + ? companyDto?.statement?.trim() ?? null + : null; const payload = { actId, preparedSha256: closure.preparedSha256, @@ -788,6 +796,8 @@ export class InspectionClosingService { documentNumber: identity.documentNumber, position: identity.position, status: InspectionActSignatureStatus.SIGNED, + companyManifestation, + companyStatement, imageSha256, consentText: identity.consentText, consentVersion: CONSENT_VERSION, @@ -806,6 +816,7 @@ export class InspectionClosingService { INSERT INTO inspection_act_signatures ( id, act_id, signer_type, signer_user_id, signer_name, document_type, document_number, position, status, + company_manifestation, company_statement, original_name, stored_name, mime_type, size_bytes, image_sha256, consent_text, consent_version, consent_accepted_at, client_signed_at, signed_at, latitude, longitude, accuracy_m, @@ -814,11 +825,12 @@ export class InspectionClosingService { ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, 'SIGNED', - $9, $10, $11, $12, $13, - $14, $15, $16, - $17, $18, $19, $20, $21, - $22, $23, $24, $25, - $26, $18 + $9, $10, + $11, $12, $13, $14, $15, + $16, $17, $18, + $19, $20, $21, $22, $23, + $24, $25, $26, $27, + $28, $20 ) `, [ id, @@ -829,6 +841,8 @@ export class InspectionClosingService { identity.documentType, identity.documentNumber, identity.position, + companyManifestation, + companyStatement, inspected.originalName, storedName, inspected.mimeType, @@ -1266,6 +1280,8 @@ export class InspectionClosingService { signature.position, signature.status, signature.reason, + signature.company_manifestation AS "companyManifestation", + signature.company_statement AS "companyStatement", signature.original_name AS "originalName", signature.stored_name AS "storedName", signature.mime_type AS "mimeType", @@ -1365,6 +1381,26 @@ export class InspectionClosingService { } } + private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void { + if (act.status !== InspectionActStatus.READY + || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY', + message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre', + }); + } + } + + private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void { + if (act.status !== InspectionActStatus.READY + || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { + throw new ConflictException({ + code: 'INSPECTION_ACT_NOT_READY', + message: 'El acta debe estar preparada para completar su cierre definitivo', + }); + } + } + private async assertActorAssigned( manager: EntityManager, visitId: string, diff --git a/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts index 31c4753..9e4b2a2 100644 --- a/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts +++ b/api-v3/src/inspection-operations/field-discovery-inspection-link.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import type { EntityManager } from 'typeorm'; export interface FieldDiscoveryInspectionLinkResult { @@ -18,7 +18,7 @@ export class FieldDiscoveryInspectionLinkService { SELECT id, status FROM inspection_acts WHERE visit_id = $1 - AND status <> 'CANCELLED' + AND status = 'DRAFT' ORDER BY created_at DESC LIMIT 1 FOR UPDATE @@ -26,12 +26,6 @@ export class FieldDiscoveryInspectionLinkService { [visitId], )) as Array<{ id: string; status: string }>; - if (act && act.status !== 'DRAFT') { - throw new ConflictException({ - code: 'FIELD_DISCOVERY_ACT_NOT_EDITABLE', - message: 'El Acta ya no admite nuevos elementos ni Hallazgos', - }); - } await manager.query( ` diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index 8b4a0fb..7039abd 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -16,6 +16,8 @@ function lines(snapshot: Record): string[] { const team=asArray(prepared.team); const assets=asArray(prepared.assets); const findings=asArray(prepared.findings); + const signatures=asArray(snapshot.signatures); + const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE'); const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))]; const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); @@ -42,6 +44,15 @@ function lines(snapshot: Record): string[] { out.push('', 'HALLAZGOS'); if(!findings.length) out.push('Sin hallazgos registrados.'); for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); } + out.push('', 'CONSTANCIA DE LA EMPRESA'); + if(!companySignature) out.push('Firma o constancia pendiente.'); + else if(text(companySignature.status,'')==='SIGNED'){ + const manifestation=text(companySignature.companyManifestation,'CONFORMITY'); + out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad'); + if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement))); + } else { + out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`)); + } out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); return out; } diff --git a/api-v3/src/inspection-visits/dto/close-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/close-inspection-visit.dto.ts new file mode 100644 index 0000000..5637ba7 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/close-inspection-visit.dto.ts @@ -0,0 +1,6 @@ +import { IsISO8601 } from 'class-validator'; + +export class CloseInspectionVisitDto { + @IsISO8601({ strict: true }) + clientClosedAt!: string; +} diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 7347650..5fb0c86 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -14,6 +14,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; +import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; @@ -154,4 +155,15 @@ export class InspectionVisitsController { ) { return this.visits.start(id, principal, request); } + + @Post(':id/close') + @RequirePermissions('inspections.execute') + close( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CloseInspectionVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.visits.close(id, dto, principal, request); + } } diff --git a/api-v3/src/inspection-visits/inspection-visits.service.ts b/api-v3/src/inspection-visits/inspection-visits.service.ts index 868ba1d..8bb29d6 100644 --- a/api-v3/src/inspection-visits/inspection-visits.service.ts +++ b/api-v3/src/inspection-visits/inspection-visits.service.ts @@ -19,6 +19,7 @@ import { InspectionVisitStatus, } from '../database/entities'; import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; +import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; @@ -810,6 +811,107 @@ export class InspectionVisitsService { }); } + + async close( + id: string, + dto: CloseInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + assertMobileInspector(principal); + return this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', + message: 'La inspección debe estar en curso para cerrarse', + }); + } + if (!principal.permissions.includes('inspections.manage')) { + const memberIds = await this.activeMemberIds(manager, id); + if (!memberIds.includes(principal.userId)) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La inspección no está asignada al usuario actual', + }); + } + } + const [acts] = (await manager.query(` + SELECT + COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts, + COUNT(*) FILTER ( + WHERE act.status = 'READY' + AND NOT EXISTS ( + SELECT 1 FROM inspection_act_signatures signature + WHERE signature.act_id = act.id + AND signature.signer_type = 'INSPECTOR' + AND signature.status = 'SIGNED' + ) + )::integer AS "readyWithoutInspector", + COUNT(*) FILTER ( + WHERE act.status = 'READY' + AND NOT EXISTS ( + SELECT 1 FROM inspection_act_signatures signature + WHERE signature.act_id = act.id + AND signature.signer_type = 'COMPANY_RESPONSIBLE' + ) + )::integer AS "pendingCompany" + FROM inspection_acts act + WHERE act.visit_id = $1 + AND act.status <> 'CANCELLED' + `, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>; + if (Number(acts?.drafts ?? 0) > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING', + message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección', + }); + } + if (Number(acts?.readyWithoutInspector ?? 0) > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING', + message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección', + }); + } + const serverClosedAt = new Date(); + const clientClosedAt = new Date(dto.clientClosedAt); + if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { + throw new BadRequestException({ + code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME', + message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la inspección', + }); + } + if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME', + message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', + }); + } + visit.status = InspectionVisitStatus.CLOSED; + visit.actualClosedAt = serverClosedAt; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + const updated = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.IN_PROGRESS }, + afterData: { + status: InspectionVisitStatus.CLOSED, + clientClosedAt: clientClosedAt.toISOString(), + serverClosedAt: serverClosedAt.toISOString(), + pendingCompanySignatures: Number(acts?.pendingCompany ?? 0), + }, + metadata: { + closeSource: 'ANDROID', + actsMayCompleteCompanySignatureLater: true, + }, + }, manager); + return updated; + }); + } + private visitSelect(where: string): string { return ` SELECT diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 601dc54..09c6416 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.19.6-4'; -export const API_PHASE = 'D5.6.4'; +export const API_VERSION = '0.20.0-1'; +export const API_PHASE = 'F1.1'; diff --git a/api-v3/test/unit/inspection-acts-single-act.test.ts b/api-v3/test/unit/inspection-acts-single-act.test.ts index 7b7768f..a365d84 100644 --- a/api-v3/test/unit/inspection-acts-single-act.test.ts +++ b/api-v3/test/unit/inspection-acts-single-act.test.ts @@ -4,37 +4,29 @@ import { ConflictException } from '@nestjs/common'; import type { DataSource, EntityManager } from 'typeorm'; import { InspectionActsService } from '../../src/inspection-acts/inspection-acts.service'; -type SingleActGuard = { - assertVisitHasNoAct(manager: EntityManager, visitId: string): Promise; +type DraftActGuard = { + assertVisitHasNoDraftAct(manager: EntityManager, visitId: string): Promise; }; -function serviceGuard(): SingleActGuard { +function serviceGuard(): DraftActGuard { return new InspectionActsService( {} as DataSource, {} as never, - ) as unknown as SingleActGuard; + ) as unknown as DraftActGuard; } -test('single-act guard allows a visit without an act', async () => { - const manager = { - query: async () => [], - } as unknown as EntityManager; - - await assert.doesNotReject( - serviceGuard().assertVisitHasNoAct(manager, 'visit-id'), - ); +test('multi-act guard allows a new act when the inspection has no draft act', async () => { + const manager = { query: async () => [] } as unknown as EntityManager; + await assert.doesNotReject(serviceGuard().assertVisitHasNoDraftAct(manager, 'visit-id')); }); -test('single-act guard rejects a second act for the same visit', async () => { - const manager = { - query: async () => [{ code: 'ACTA-2026-000001' }], - } as unknown as EntityManager; - +test('multi-act guard blocks only a second draft act for the same inspection', async () => { + const manager = { query: async () => [{ code: 'ACTA-2026-000001' }] } as unknown as EntityManager; await assert.rejects( - serviceGuard().assertVisitHasNoAct(manager, 'visit-id'), + serviceGuard().assertVisitHasNoDraftAct(manager, 'visit-id'), (error: unknown) => error instanceof ConflictException && error.getResponse() instanceof Object && (error.getResponse() as { code?: string }).code - === 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', + === 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', ); }); diff --git a/api-v3/test/unit/inspection-visits-authorization.test.ts b/api-v3/test/unit/inspection-visits-authorization.test.ts index c7751c1..450d2a7 100644 --- a/api-v3/test/unit/inspection-visits-authorization.test.ts +++ b/api-v3/test/unit/inspection-visits-authorization.test.ts @@ -20,4 +20,5 @@ test('inspection visit endpoints separate reading, planning, assignment and exec assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']); assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']); assert.deepEqual(permissionFor('start'), ['inspections.execute']); + assert.deepEqual(permissionFor('close'), ['inspections.execute']); }); diff --git a/api-v3/test/unit/mobile-inspector-integration.test.ts b/api-v3/test/unit/mobile-inspector-integration.test.ts index afd10d4..40917e1 100644 --- a/api-v3/test/unit/mobile-inspector-integration.test.ts +++ b/api-v3/test/unit/mobile-inspector-integration.test.ts @@ -19,7 +19,7 @@ test('D5.1 applies the mobile-inspector policy to every field operation', async source('inspection-findings/inspection-evidence.service.ts'), source('inspection-closing/inspection-closing.service.ts'), ]); - assert.equal(policyCalls(visits), 1); + assert.equal(policyCalls(visits), 2); assert.equal(policyCalls(acts), 3); assert.equal(policyCalls(findings), 2); assert.equal(policyCalls(evidence), 1); diff --git a/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts b/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts index ac9e7ef..ee8e3a2 100644 --- a/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts +++ b/api-v3/test/unit/phase-d5-3-19-inventory-filters-deadlines.test.ts @@ -46,9 +46,9 @@ test('D5.3.19 exposes company area inspector and date filters across operational } }); -test('D5.3.19 keeps one visit equal to one act', () => { - assert.match(actsService, /await this\.assertVisitHasNoAct\(manager, visitId\)/); - assert.match(actsService, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); +test('F1.1 supersedes the historical single-act rule while retaining the D5.3.19 filters', () => { + assert.match(actsService, /await this\.assertVisitHasNoDraftAct\(manager, visitId\)/); + assert.match(actsService, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); }); test('D5.3.19 does not introduce the contextual finding catalog before its office definition', () => { diff --git a/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts index da3d897..9a9b157 100644 --- a/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts +++ b/api-v3/test/unit/phase-d5-3-20-automatic-word-report.test.ts @@ -35,7 +35,7 @@ test('D5.3.20 builds OOXML Word from the frozen act snapshot without external do assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i); }); -test('D5.3.20 preserves one visit one act and leaves email delivery for a configured phase', () => { +test('D5.3.20 keeps frozen report generation act-scoped and leaves email delivery for a configured phase', () => { assert.match(closing, /findingsRemainOpen: true/); assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i); }); diff --git a/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts index dc6d36e..43bbdac 100644 --- a/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts +++ b/api-v3/test/unit/phase-d5-3-21-document-delivery.test.ts @@ -4,4 +4,4 @@ test('D5.3.21 stores institutional and company recipients without hardcoding add test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);}); test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);}); test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);}); -test('D5.3.21 triggers document delivery after the frozen report Word step without changing one visit one act',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoAct/);assert.match(acts,/INSPECTION_VISIT_ACT_ALREADY_EXISTS/);}); +test('D5.3.21 delivery stays act-scoped after F1.1 enables multiple acts',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoDraftAct/);assert.match(acts,/INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);}); diff --git a/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts index 69cf705..4b95d1c 100644 --- a/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts +++ b/api-v3/test/unit/phase-d5-3-22-director-report-review.test.ts @@ -48,8 +48,8 @@ test('D5.3.22 binds the final Director signature to report and revision hashes', }); test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => { - assert.match(acts, /assertVisitHasNoAct/); - assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); + assert.match(acts, /assertVisitHasNoDraftAct/); + assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/); assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/); diff --git a/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts b/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts index 21a265c..a93f189 100644 --- a/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts +++ b/api-v3/test/unit/phase-d5-3-23-field-discoveries.test.ts @@ -26,12 +26,14 @@ test('D5.3.23 only lets a mobile assigned inspector create during an active visi assert.match(assets, /FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED/); }); -test('D5.3.23 immediately links the provisional object to the visit and open act', () => { +test('F1.1 preserves D5.3.23 field discovery while linking only to the current draft act', () => { assert.match(assets, /fieldDiscoveryInspectionLinks\.attach/); assert.doesNotMatch(assets, /INSERT INTO inspection_/); assert.match(inspectionLinks, /INSERT INTO inspection_visit_assets/); assert.match(inspectionLinks, /INSERT INTO inspection_act_assets/); - assert.match(inspectionLinks, /FIELD_DISCOVERY_ACT_NOT_EDITABLE/); + assert.match(inspectionLinks, /status = 'DRAFT'/); + assert.match(inspectionLinks, /if \(act\)/); + assert.match(inspectionLinks, /actId: act\?\.id \?\? null/); assert.match(findings, /assertActAsset/); }); diff --git a/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts index ea91cbc..361e6d5 100644 --- a/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts +++ b/api-v3/test/unit/phase-d5-6-phase-d-closure.test.ts @@ -6,10 +6,10 @@ import test from 'node:test'; const root = process.cwd(); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); -test('D5.6 preserves one visit one immutable act with a frozen closure snapshot', () => { +test('D5.6 immutable act closure remains intact after the multi-act transition', () => { const acts = read('src/inspection-acts/inspection-acts.service.ts'); const closing = read('src/inspection-closing/inspection-closing.service.ts'); - assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); + assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/); assert.match(closing, /final_snapshot = \$2/); assert.match(closing, /final_sha256 = \$3/); assert.match(closing, /InspectionActVersionEvent\.CLOSED/); @@ -74,10 +74,8 @@ test('D5.6 preserves auditable document delivery while allowing SMTP to remain p assert.match(smtp, /MAIL_FROM/); }); -test('D5.6 declares a stable Phase D closure across maintenance hotfixes without introducing another functional migration', () => { - const apiVersion = read('src/version.ts'); - assert.match(apiVersion, /API_VERSION = '0\.19\.6(?:-\d+)?'/); - assert.match(apiVersion, /API_PHASE = 'D5\.6(?:\.\d+)?'/); +test('D5.6 remains a historical closure baseline while Phase F introduces an explicit new migration', () => { const migrations = fs.readdirSync(path.join(root, 'src/database/migrations')); assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false); + assert.equal(migrations.some((name) => /phase-f1-1-multi-act-inspections/i.test(name)), true); }); diff --git a/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts b/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts new file mode 100644 index 0000000..f563b06 --- /dev/null +++ b/api-v3/test/unit/phase-f1-1-multi-act-operating-model.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const root = process.cwd(); +const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); + +test('F1.1 migration allows many acts per inspection while keeping only one draft at a time', () => { + const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts'); + assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/); + assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/); + assert.match(migration, /WHERE status = 'DRAFT'/); + assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_reports_visit/); + assert.match(migration, /CREATE INDEX idx_inspection_reports_visit_id/); +}); + +test('F1.1 preserves exactly one frozen report per act instead of per inspection', () => { + const reportEntity = read('src/database/entities/inspection-report.entity.ts'); + const reports = read('src/inspection-reports/inspection-reports.service.ts'); + assert.match(reportEntity, /@Index\('uq_inspection_reports_act', \['actId'\], \{ unique: true \}\)/); + assert.match(reportEntity, /@Index\('idx_inspection_reports_visit_id', \['visitId'\]\)/); + assert.doesNotMatch(reportEntity, /visitId'\], \{ unique: true \}/); + assert.match(reports, /WHERE act_id = \$1/); +}); + +test('F1.1 closes an act without closing its parent inspection', () => { + const closing = read('src/inspection-closing/inspection-closing.service.ts'); + const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent(')); + assert.match(closeBody, /UPDATE inspection_acts/); + assert.doesNotMatch(closeBody, /UPDATE inspection_visits/); + assert.match(closeBody, /visitRemainsIndependent: true/); + assert.match(closeBody, /ensureFrozenReport\(manager, actId, principal, request\)/); +}); + +test('F1.1 exposes an APK-only explicit inspection close and permits pending company signature', () => { + const controller = read('src/inspection-visits/inspection-visits.controller.ts'); + const visits = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(controller, /@Post\(':id\/close'\)/); + assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/); + assert.match(visits, /async close\(/); + assert.match(visits, /assertMobileInspector\(principal\)/); + assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/); + assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/); + assert.match(visits, /pendingCompanySignatures/); + assert.match(visits, /actsMayCompleteCompanySignatureLater: true/); +}); + +test('F1.1 lets the company sign after the inspection closes and records conformity or dissent', () => { + const closing = read('src/inspection-closing/inspection-closing.service.ts'); + const dto = read('src/inspection-closing/dto/create-company-signature.dto.ts'); + const signatureEntity = read('src/database/entities/inspection-act-signature.entity.ts'); + assert.match(closing, /InspectionVisitStatus\.IN_PROGRESS, InspectionVisitStatus\.CLOSED/); + assert.match(closing, /InspectionCompanySignatureManifestation\.CONFORMITY/); + assert.match(closing, /InspectionCompanySignatureManifestation\.DISSENT/); + assert.match(dto, /@ValidateIf/); + assert.match(dto, /@MinLength\(10\)/); + assert.match(signatureEntity, /CONFORMITY = 'CONFORMITY'/); + assert.match(signatureEntity, /DISSENT = 'DISSENT'/); + assert.match(signatureEntity, /companyStatement/); +}); + +test('F1.1 keeps field-created Inventory linked only to the current draft act', () => { + const link = read('src/inspection-operations/field-discovery-inspection-link.service.ts'); + assert.match(link, /AND status = 'DRAFT'/); + assert.match(link, /if \(act\)/); + assert.match(link, /inspection_visit_assets/); + assert.match(link, /inspection_act_assets/); + assert.match(link, /return \{ actId: act\?\.id \?\? null \}/); + assert.doesNotMatch(link, /status <> 'CANCELLED'/); +}); diff --git a/web-v2/package.json b/web-v2/package.json index f6088fa..85ee2d7 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.19.6-4", + "version": "0.20.0-1", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 85668f7..4bc46f6 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.19.6-4'; -export const APP_PHASE = 'Fase D5.6.4 · Inspecciones simples y combobox buscables'; +export const APP_VERSION = '0.20.0-1'; +export const APP_PHASE = 'Fase F1.1 · Inspección multi-Acta y firma diferida'; diff --git a/web-v2/src/features/inspections/InspectionActsPanel.tsx b/web-v2/src/features/inspections/InspectionActsPanel.tsx index b62fd2c..c8b44a5 100644 --- a/web-v2/src/features/inspections/InspectionActsPanel.tsx +++ b/web-v2/src/features/inspections/InspectionActsPanel.tsx @@ -29,13 +29,13 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) { return
-
ACTA DE LA VISITA

Acta única

Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.

+
ACTAS DE LA INSPECCIÓN

Actas generadas

Una inspección puede generar múltiples actas. Cada acta agrupa sus propios hallazgos y tendrá un único informe.

Sólo lectura
-

El acta se crea en la APK. El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.

+

Las actas se crean en la APK. El dashboard permite consultar cada acta, sus hallazgos, firmas e informe después de la sincronización.

{error && {error}} {loading ? : acts.length === 0 - ? - :
Acta de inspecciónNumeración oficial global por año
{acts.map((act) => )}
ActaEstadoFechaRegistrosHallazgosVersión
{act.title}{act.code}
{inspectionActStatusLabel(act.status)}{formatDate(act.occurredAt)}{act.assetCount}{act.findingCount}v{act.currentVersion}
} + ? + :
Actas de la inspecciónNumeración oficial global por año
{acts.map((act) => )}
ActaEstadoFechaRegistrosHallazgosVersión
{act.title}{act.code}
{inspectionActStatusLabel(act.status)}{formatDate(act.occurredAt)}{act.assetCount}{act.findingCount}v{act.currentVersion}
}
; } diff --git a/web-v2/src/features/inspections/InspectionClosurePanel.tsx b/web-v2/src/features/inspections/InspectionClosurePanel.tsx index 059fe8c..1fc3a90 100644 --- a/web-v2/src/features/inspections/InspectionClosurePanel.tsx +++ b/web-v2/src/features/inspections/InspectionClosurePanel.tsx @@ -62,7 +62,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) { const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome); return
-
CIERRE DE LA INSPECCIÓN · SÓLO LECTURA

Responsable, firmas y sellado

La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.

{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}
+
CIERRE DEL ACTA · SÓLO LECTURA

Responsable, firmas y sellado

La preparación y firma del acta se realizan desde la APK. La inspección puede continuar con otras actas.

{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}
{error && {error}}

Operación exclusiva en APK. El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.

@@ -78,8 +78,8 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) { {closure.closure?.isCurrent &&
CONTENIDO CONGELADO{closure.closure.schemaVersion}Preparado {formatDate(closure.closure.preparedAt)}
{closure.closure.preparedSha256}
} - {closure.signatures.length > 0 &&
{closure.signatures.map((signature) =>
{signatureStatusLabel(signature.status)}{signature.signerName}{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}
{signature.signaturePayloadSha256}{signature.status === 'SIGNED' ? :

{signature.reason}

}
)}
} + {closure.signatures.length > 0 &&
{closure.signatures.map((signature) =>
{signatureStatusLabel(signature.status)}{signature.signerName}{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}
{signature.signaturePayloadSha256}{signature.status === 'SIGNED' ? <>{signature.signerType === 'COMPANY_RESPONSIBLE' &&

{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}

} :

{signature.reason}

}
)}
} - {act.status === 'CLOSED' && closure.closure?.finalSha256 &&
ACTA Y VISITA CERRADAS{formatDate(closure.closure.serverClosedAt)}

Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.

{closure.closure.finalSha256}
} + {act.status === 'CLOSED' && closure.closure?.finalSha256 &&
ACTA CERRADA{formatDate(closure.closure.serverClosedAt)}

El acta quedó sellada. La inspección y las demás actas tienen un ciclo independiente.

{closure.closure.finalSha256}
}
; } diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index b37db7a..9d6a8d0 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -1129,6 +1129,8 @@ export interface InspectionActSignature { position: string | null; status: InspectionActSignatureStatus; reason: string | null; + companyManifestation: 'CONFORMITY' | 'DISSENT' | null; + companyStatement: string | null; mimeType: string | null; sizeBytes: number | null; imageSha256: string | null; diff --git a/web-v2/src/lib/format.ts b/web-v2/src/lib/format.ts index 52bc180..910ec9f 100644 --- a/web-v2/src/lib/format.ts +++ b/web-v2/src/lib/format.ts @@ -76,7 +76,7 @@ const actions: Record = { INSPECTION_ACT_REOPENED: 'Acta devuelta a borrador', INSPECTION_ACT_SIGNATURE_RECORDED: 'Firma manuscrita incorporada al acta', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED: 'Resultado de recepción empresarial registrado', - INSPECTION_ACT_CLOSED: 'Acta y visita cerradas', + INSPECTION_ACT_CLOSED: 'Acta cerrada', INSPECTION_REPORT_GENERATED: 'Informe de inspección generado', INSPECTION_VERIFICATION_PLANNED: 'Visita de verificación preparada', INSPECTION_FINDING_CREATED: 'Hallazgo abierto', diff --git a/web-v2/src/pages/InspectionVisitEditorPage.tsx b/web-v2/src/pages/InspectionVisitEditorPage.tsx index fb801a4..223e655 100644 --- a/web-v2/src/pages/InspectionVisitEditorPage.tsx +++ b/web-v2/src/pages/InspectionVisitEditorPage.tsx @@ -404,7 +404,7 @@ export function InspectionVisitEditorPage() { {visit &&
Flujo de la inspección

Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.

{canManage && visit.status === 'DRAFT' && }{canManage && visit.status === 'PLANNED' && }{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && }
} {visit?.status === 'PLANNED' &&

Planificación lista. El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.

} - {visit?.status === 'IN_PROGRESS' &&

Inspección iniciada {formatDate(visit.actualStartedAt)}. El acta, hallazgos, evidencias y cierre se registran desde la APK; aquí se consultan.

} + {visit?.status === 'IN_PROGRESS' &&

Inspección iniciada {formatDate(visit.actualStartedAt)}. Las actas, hallazgos, evidencias y el cierre de la inspección se registran desde la APK; aquí se consultan.

} {visit?.status === 'CANCELLED' && Cancelada: {visit.cancellationReason}} ; } diff --git a/web-v2/src/pages/InspectionVisitsPage.tsx b/web-v2/src/pages/InspectionVisitsPage.tsx index a41f903..34f63c3 100644 --- a/web-v2/src/pages/InspectionVisitsPage.tsx +++ b/web-v2/src/pages/InspectionVisitsPage.tsx @@ -100,7 +100,7 @@ export function InspectionVisitsPage() { {statusTabs.map((item) => {item.label})} -

Un solo ciclo. La planificación, ejecución, Acta, Hallazgos e Informe pertenecen a la misma inspección. El inicio en campo sigue siendo exclusivo de la APK.

+

Un recorrido, varias actas. La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.