From 48bbe293d94a68be8addb951ac8cdb90e4d7b8ee Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:43:40 -0300 Subject: [PATCH 001/224] feat(f4): extend act lifecycle and deadline snapshot --- .../entities/inspection-act.entity.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/api-v3/src/database/entities/inspection-act.entity.ts b/api-v3/src/database/entities/inspection-act.entity.ts index d7e57be..3e9021f 100644 --- a/api-v3/src/database/entities/inspection-act.entity.ts +++ b/api-v3/src/database/entities/inspection-act.entity.ts @@ -3,12 +3,29 @@ import { TimestampedEntity } from './timestamped.entity'; export enum InspectionActStatus { DRAFT = 'DRAFT', + LOCKED = 'LOCKED', + SEALED = 'SEALED', READY = 'READY', CLOSED = 'CLOSED', CANCELLED = 'CANCELLED', RECTIFIED = 'RECTIFIED', } +export enum InspectionActUrgency { + URGENT = 'URGENT', + NON_URGENT = 'NON_URGENT', +} + +export enum InspectionDeadlineDayType { + BUSINESS = 'BUSINESS', + CALENDAR = 'CALENDAR', +} + +export enum InspectionDeadlineBasis { + ACT_DATE = 'ACT_DATE', + GEDO_DATE = 'GEDO_DATE', +} + @Entity({ name: 'inspection_acts' }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @@ -16,6 +33,7 @@ export enum InspectionActStatus { @Index('idx_inspection_acts_visit_status', ['visitId', 'status']) @Index('idx_inspection_acts_occurred_at', ['occurredAt']) @Index('idx_inspection_acts_created_by', ['createdBy']) +@Index('idx_inspection_acts_deadline', ['deadlineAt']) export class InspectionAct extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -29,7 +47,7 @@ export class InspectionAct extends TimestampedEntity { @Column({ name: 'act_number', type: 'integer' }) actNumber!: number; - @Column({ type: 'varchar', length: 24 }) + @Column({ type: 'varchar', length: 40 }) code!: string; @Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT }) @@ -47,6 +65,39 @@ export class InspectionAct extends TimestampedEntity { @Column({ type: 'text', nullable: true }) observations!: string | null; + @Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT }) + urgency!: InspectionActUrgency; + + @Column({ name: 'deadline_days', type: 'integer', nullable: true }) + deadlineDays!: number | null; + + @Column({ name: 'deadline_day_type', type: 'varchar', length: 24, nullable: true }) + deadlineDayType!: InspectionDeadlineDayType | null; + + @Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true }) + deadlineBasis!: InspectionDeadlineBasis | null; + + @Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true }) + deadlineBaseAt!: Date | null; + + @Column({ name: 'deadline_at', type: 'timestamptz', nullable: true }) + deadlineAt!: Date | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt!: Date | null; + + @Column({ name: 'locked_by', type: 'uuid', nullable: true }) + lockedBy!: string | null; + + @Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true }) + lockedSha256!: string | null; + + @Column({ name: 'sealed_at', type: 'timestamptz', nullable: true }) + sealedAt!: Date | null; + + @Column({ name: 'sealed_by', type: 'uuid', nullable: true }) + sealedBy!: string | null; + @Column({ name: 'current_version', type: 'integer', default: 0 }) currentVersion!: number; From 7a41ce25ad6d8d0a2e1c6f534df0ff27e1a3a220 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:43:55 -0300 Subject: [PATCH 002/224] feat(f4): add finding recurrence linkage --- .../src/database/entities/inspection-finding.entity.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api-v3/src/database/entities/inspection-finding.entity.ts b/api-v3/src/database/entities/inspection-finding.entity.ts index e1f5a69..67e3631 100644 --- a/api-v3/src/database/entities/inspection-finding.entity.ts +++ b/api-v3/src/database/entities/inspection-finding.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; +/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */ export enum InspectionFindingResponseDueBasis { FINDING_DATE = 'FINDING_DATE', REPORT_NOTIFICATION = 'REPORT_NOTIFICATION', @@ -18,6 +19,7 @@ export enum InspectionFindingStatus { @Index('idx_inspection_findings_status_control', ['status', 'nextControlOn']) @Index('idx_inspection_findings_asset_status', ['assetId', 'status']) @Index('idx_inspection_findings_catalog_item', ['catalogItemId']) +@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId']) export class InspectionFinding extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity { @Column({ type: 'smallint', nullable: true }) severity!: number | null; + @Column({ name: 'is_recurrence', type: 'boolean', default: false }) + isRecurrence!: boolean; + + @Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true }) + recurrenceOfFindingId!: string | null; + @Column({ name: 'correction_due_on', type: 'date', nullable: true }) correctionDueOn!: string | null; + // Campos legacy conservados temporalmente para migrar datos sin pérdida. @Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true }) responseDueBasis!: InspectionFindingResponseDueBasis | null; From a9efd71367a099e850a9fb54e49ba5b2b84427bc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:44:12 -0300 Subject: [PATCH 003/224] feat(f4): extend INF with GEDO officialization --- .../entities/inspection-report.entity.ts | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/api-v3/src/database/entities/inspection-report.entity.ts b/api-v3/src/database/entities/inspection-report.entity.ts index 77c4291..7742475 100644 --- a/api-v3/src/database/entities/inspection-report.entity.ts +++ b/api-v3/src/database/entities/inspection-report.entity.ts @@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; import { TimestampedEntity } from './timestamped.entity'; export enum InspectionReportStatus { + WORKING = 'WORKING', + OFFICIALIZED = 'OFFICIALIZED', FROZEN = 'FROZEN', CANCELLED = 'CANCELLED', } @@ -18,6 +20,7 @@ export enum InspectionReportWordStatus { FAILED = 'FAILED', } +/** @deprecated El circuito Director/aprobación/firma se retira en F4. */ export enum InspectionReportReviewStatus { PENDING_REVIEW = 'PENDING_REVIEW', APPROVED = 'APPROVED', @@ -31,6 +34,7 @@ export enum InspectionReportReviewStatus { @Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('idx_inspection_reports_generated_at', ['generatedAt']) @Index('idx_inspection_reports_status', ['status', 'pdfStatus']) +@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt']) export class InspectionReport extends TimestampedEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -47,12 +51,39 @@ export class InspectionReport extends TimestampedEntity { @Column({ name: 'report_number', type: 'integer' }) reportNumber!: number; - @Column({ type: 'varchar', length: 24 }) + @Column({ type: 'varchar', length: 40 }) code!: string; - @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN }) + @Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING }) status!: InspectionReportStatus; + @Column({ name: 'executive_summary', type: 'text', nullable: true }) + executiveSummary!: string | null; + + @Column({ name: 'report_description', type: 'text', nullable: true }) + reportDescription!: string | null; + + @Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true }) + gedoIfIdentifier!: string | null; + + @Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true }) + gedoOfficializedAt!: Date | null; + + @Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true }) + gedoPdfOriginalName!: string | null; + + @Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true }) + gedoPdfStoredName!: string | null; + + @Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true }) + gedoPdfMimeType!: string | null; + + @Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true }) + gedoPdfSizeBytes!: number | null; + + @Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true }) + gedoPdfSha256!: string | null; + @Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING }) pdfStatus!: InspectionReportPdfStatus; @@ -80,6 +111,7 @@ export class InspectionReport extends TimestampedEntity { @Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true }) wordError!: string | null; + // Campos legacy del circuito Director, conservados sólo hasta completar la migración F4. @Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW }) reviewStatus!: InspectionReportReviewStatus; From 52a068c81754667d90958aa4177766a9a6980b6f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:44:21 -0300 Subject: [PATCH 004/224] feat(f4): add JEFE-managed act deadline policy --- .../inspection-deadline-policy.entity.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 api-v3/src/database/entities/inspection-deadline-policy.entity.ts diff --git a/api-v3/src/database/entities/inspection-deadline-policy.entity.ts b/api-v3/src/database/entities/inspection-deadline-policy.entity.ts new file mode 100644 index 0000000..9072f55 --- /dev/null +++ b/api-v3/src/database/entities/inspection-deadline-policy.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { InspectionDeadlineDayType } from './inspection-act.entity'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_deadline_policies' }) +export class InspectionDeadlinePolicy extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'urgent_days', type: 'integer', default: 5 }) + urgentDays!: number; + + @Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS }) + urgentDayType!: InspectionDeadlineDayType; + + @Column({ name: 'non_urgent_days', type: 'integer', default: 10 }) + nonUrgentDays!: number; + + @Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS }) + nonUrgentDayType!: InspectionDeadlineDayType; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} From 2fb48ec41e01e9891a28729869d47870d37c2bd1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:44:27 -0300 Subject: [PATCH 005/224] feat(f4): add business-day calendar overrides --- ...inspection-business-calendar-day.entity.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 api-v3/src/database/entities/inspection-business-calendar-day.entity.ts diff --git a/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts b/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts new file mode 100644 index 0000000..0a7752f --- /dev/null +++ b/api-v3/src/database/entities/inspection-business-calendar-day.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +@Entity({ name: 'inspection_business_calendar_days' }) +@Index('uq_inspection_business_calendar_day', ['date'], { unique: true }) +export class InspectionBusinessCalendarDay extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'date' }) + date!: string; + + @Column({ name: 'is_business_day', type: 'boolean', default: false }) + isBusinessDay!: boolean; + + @Column({ type: 'varchar', length: 200 }) + label!: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} From 13f2dcfa959b54d5d17eb0506d1e4cad3b2e57ba Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:44:34 -0300 Subject: [PATCH 006/224] feat(f4): add append-only INF follow-up timeline --- .../inspection-report-follow-up.entity.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 api-v3/src/database/entities/inspection-report-follow-up.entity.ts diff --git a/api-v3/src/database/entities/inspection-report-follow-up.entity.ts b/api-v3/src/database/entities/inspection-report-follow-up.entity.ts new file mode 100644 index 0000000..e7b26e4 --- /dev/null +++ b/api-v3/src/database/entities/inspection-report-follow-up.entity.ts @@ -0,0 +1,50 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum InspectionReportFollowUpType { + COMPANY_NOTE = 'COMPANY_NOTE', + COMPANY_DOCUMENT = 'COMPANY_DOCUMENT', + INTERNAL_NOTE = 'INTERNAL_NOTE', + VERIFICATION = 'VERIFICATION', + OTHER = 'OTHER', +} + +@Entity({ name: 'inspection_report_follow_ups' }) +@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt']) +export class InspectionReportFollowUp extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'report_id', type: 'uuid' }) + reportId!: string; + + @Column({ type: 'varchar', length: 40 }) + type!: InspectionReportFollowUpType; + + @Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true }) + externalReference!: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true }) + originalName!: string | null; + + @Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true }) + storedName!: string | null; + + @Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true }) + mimeType!: string | null; + + @Column({ name: 'size_bytes', type: 'integer', nullable: true }) + sizeBytes!: number | null; + + @Column({ name: 'sha256', type: 'char', length: 64, nullable: true }) + sha256!: string | null; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy!: string | null; +} From ab665e51b51bf763dbe919a995ec3d681fdd6adf Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:44:42 -0300 Subject: [PATCH 007/224] feat(f4): add superadmin SMTP settings entity --- .../entities/system-smtp-settings.entity.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 api-v3/src/database/entities/system-smtp-settings.entity.ts diff --git a/api-v3/src/database/entities/system-smtp-settings.entity.ts b/api-v3/src/database/entities/system-smtp-settings.entity.ts new file mode 100644 index 0000000..06ae268 --- /dev/null +++ b/api-v3/src/database/entities/system-smtp-settings.entity.ts @@ -0,0 +1,44 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TimestampedEntity } from './timestamped.entity'; + +export enum SmtpSecurityMode { + NONE = 'NONE', + STARTTLS = 'STARTTLS', + TLS = 'TLS', +} + +@Entity({ name: 'system_smtp_settings' }) +export class SystemSmtpSettings extends TimestampedEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 255 }) + host!: string; + + @Column({ type: 'integer' }) + port!: number; + + @Column({ name: 'security_mode', type: 'varchar', length: 24, default: SmtpSecurityMode.STARTTLS }) + securityMode!: SmtpSecurityMode; + + @Column({ type: 'varchar', length: 255, nullable: true }) + username!: string | null; + + @Column({ name: 'password_enc', type: 'text', nullable: true }) + passwordEnc!: string | null; + + @Column({ name: 'from_name', type: 'varchar', length: 200 }) + fromName!: string; + + @Column({ name: 'from_email', type: 'varchar', length: 320 }) + fromEmail!: string; + + @Column({ name: 'reply_to', type: 'varchar', length: 320, nullable: true }) + replyTo!: string | null; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy!: string | null; +} From 0f1164fae8274d81f9c17d6c10631d3e7ef80fa7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:45:04 -0300 Subject: [PATCH 008/224] feat(f4): register deadline, follow-up and SMTP entities --- api-v3/src/database/entities/index.ts | 99 +++++++++------------------ 1 file changed, 31 insertions(+), 68 deletions(-) diff --git a/api-v3/src/database/entities/index.ts b/api-v3/src/database/entities/index.ts index 4b9d2e4..cd1cff6 100644 --- a/api-v3/src/database/entities/index.ts +++ b/api-v3/src/database/entities/index.ts @@ -6,14 +6,8 @@ export { Role } from './role.entity'; export { UserRole } from './user-role.entity'; export { User, UserStatus } from './user.entity'; export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity'; -export { - AssetVersion, - AssetVersionChangeType, -} from './asset-version.entity'; -export { - AssetAttributeDataType, - AssetAttributeDefinition, -} from './asset-attribute-definition.entity'; +export { AssetVersion, AssetVersionChangeType } from './asset-version.entity'; +export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity'; export { AssetAttributeValue } from './asset-attribute-value.entity'; export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity'; export { OrganizationProfile, OrganizationKind } from './organization-profile.entity'; @@ -25,47 +19,23 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity'; export { AssetTypeParentRule } from './asset-type-parent-rule.entity'; export { AssetType, AssetTypeOperationalRole } from './asset-type.entity'; -export { - AssetGeometry, - AssetGeometrySource, - AssetGeometryType, -} from './asset-geometry.entity'; -export { - AssetMedia, - AssetMediaKind, - AssetMediaSource, -} from './asset-media.entity'; -export { - SurveyCampaign, - SurveyCampaignStatus, -} from './survey-campaign.entity'; -export { - SurveyCampaignTarget, - SurveyTargetStatus, -} from './survey-campaign-target.entity'; -export { - SurveyReportOutcome, - SurveyReportStatus, - SurveyTargetReport, -} from './survey-target-report.entity'; +export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity'; +export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity'; +export { SurveyCampaign, SurveyCampaignStatus } from './survey-campaign.entity'; +export { SurveyCampaignTarget, SurveyTargetStatus } from './survey-campaign-target.entity'; +export { SurveyReportOutcome, SurveyReportStatus, SurveyTargetReport } from './survey-target-report.entity'; export { SurveyTargetReportMedia } from './survey-target-report-media.entity'; -export { - SurveyReportVersionEvent, - SurveyTargetReportVersion, -} from './survey-target-report-version.entity'; -export { - InspectionVisit, - InspectionVisitStatus, -} from './inspection-visit.entity'; +export { SurveyReportVersionEvent, SurveyTargetReportVersion } from './survey-target-report-version.entity'; +export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity'; export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity'; export { InspectionVisitMember } from './inspection-visit-member.entity'; -export { - DocumentAnnualSequence, - DocumentSequenceType, -} from './document-annual-sequence.entity'; +export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity'; export { InspectionAct, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, } from './inspection-act.entity'; export { InspectionActAsset } from './inspection-act-asset.entity'; export { @@ -75,19 +45,13 @@ export { InspectionReportStatus, InspectionReportWordStatus, } from './inspection-report.entity'; -export { - InspectionActVersion, - InspectionActVersionEvent, -} from './inspection-act-version.entity'; +export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity'; export { InspectionActResponsible, InspectionResponsibleAttendanceStatus, InspectionResponsibleDocumentType, } from './inspection-act-responsible.entity'; -export { - InspectionActClosure, - InspectionActUploadMode, -} from './inspection-act-closure.entity'; +export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity'; export { InspectionActSignature, InspectionActSignatureSource, @@ -95,21 +59,18 @@ export { InspectionActSignerType, InspectionCompanySignatureManifestation, } from './inspection-act-signature.entity'; +export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity'; +export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity'; +export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity'; +export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.entity'; export { FindingCategory } from './finding-category.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity'; export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity'; export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity'; -export { - InspectionFinding, - InspectionFindingResponseDueBasis, - InspectionFindingStatus, -} from './inspection-finding.entity'; -export { - InspectionFindingVersion, - InspectionFindingVersionEvent, -} from './inspection-finding-version.entity'; +export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity'; +export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity'; export { InspectionCommunicationChannel, InspectionCommunicationDirection, @@ -122,14 +83,8 @@ export { InspectionEvidenceSource, InspectionFindingEvidence, } from './inspection-finding-evidence.entity'; -export { - InspectionFindingVerificationVisit, - InspectionVerificationOutcome, -} from './inspection-finding-verification-visit.entity'; -export { - InspectionFindingVerificationEvent, - InspectionFindingVerificationEventType, -} from './inspection-finding-verification-event.entity'; +export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity'; +export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity'; import { AuditEvent } from './audit-event.entity'; import { AuthSession } from './auth-session.entity'; @@ -170,6 +125,10 @@ import { InspectionActVersion } from './inspection-act-version.entity'; import { InspectionActResponsible } from './inspection-act-responsible.entity'; import { InspectionActClosure } from './inspection-act-closure.entity'; import { InspectionActSignature } from './inspection-act-signature.entity'; +import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity'; +import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity'; +import { InspectionReportFollowUp } from './inspection-report-follow-up.entity'; +import { SystemSmtpSettings } from './system-smtp-settings.entity'; import { FindingCategory } from './finding-category.entity'; import { FindingCatalogItem } from './finding-catalog-item.entity'; import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity'; @@ -223,6 +182,10 @@ export const PHASE_A_ENTITIES = [ InspectionActResponsible, InspectionActClosure, InspectionActSignature, + InspectionDeadlinePolicy, + InspectionBusinessCalendarDay, + InspectionReportFollowUp, + SystemSmtpSettings, FindingCategory, FindingCatalogItem, FindingCatalogItemAssetType, From c97e5ae33c2845b4f4737f41c6e41e8f6545d2f8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:45:29 -0300 Subject: [PATCH 009/224] feat(f4): add document workflow foundation migration --- ...0000000-f4-document-workflow-foundation.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts diff --git a/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts b/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts new file mode 100644 index 0000000..6eec43d --- /dev/null +++ b/api-v3/src/database/migrations/1790000000000-f4-document-workflow-foundation.ts @@ -0,0 +1,161 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface { + name = 'F4DocumentWorkflowFoundation1790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`); + await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`); + + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`); + + await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE inspection_findings + ADD CONSTRAINT fk_inspection_findings_recurrence_of + FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT; + EXCEPTION WHEN duplicate_object THEN NULL; END $$; + `); + + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`); + await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_deadline_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0), + urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS', + non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0), + non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS', + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')), + CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR')) + ) + `); + await queryRunner.query(` + INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type) + SELECT 5, 'BUSINESS', 10, 'BUSINESS' + WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_business_calendar_days ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + date date NOT NULL, + is_business_day boolean NOT NULL DEFAULT false, + label varchar(200) NOT NULL, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date) + ) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inspection_report_follow_ups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + report_id uuid NOT NULL, + type varchar(40) NOT NULL, + external_reference varchar(255), + occurred_at timestamptz NOT NULL, + description text, + original_name varchar(255), + stored_name varchar(255), + mime_type varchar(120), + size_bytes integer, + sha256 char(64), + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE, + CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER')) + ) + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS system_smtp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + host varchar(255) NOT NULL, + port integer NOT NULL CHECK (port > 0 AND port <= 65535), + security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS', + username varchar(255), + password_enc text, + from_name varchar(200) NOT NULL, + from_email varchar(320) NOT NULL, + reply_to varchar(320), + enabled boolean NOT NULL DEFAULT true, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS')) + ) + `); + + await queryRunner.query(` + UPDATE inspection_acts + SET + deadline_days = COALESCE(deadline_days, 10), + deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'), + deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE') + WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`); + await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`); + + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`); + await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`); + + await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`); + await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`); + await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`); + + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`); + await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`); + } +} From d834bc7b827e0eb0c7442442b36d7aaed5889682 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:45:49 -0300 Subject: [PATCH 010/224] refactor(f4): remove Director report review circuit --- api-v3/src/inspection-reports/inspection-reports.module.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index a997459..3ef820c 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -3,8 +3,6 @@ import { AuditModule } from '../audit/audit.module'; import { DocumentDeliveryController } from './document-delivery.controller'; import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; -import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller'; -import { InspectionReportReviewService } from './inspection-report-review.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; @@ -16,8 +14,6 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionReportsController, InspectionActReportController, DocumentDeliveryController, - InspectionReportReviewController, - InspectionReportRevisionContentController, ], providers: [ InspectionReportsService, @@ -25,7 +21,6 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionActPdfService, InspectionDocumentDeliveryService, SmtpDeliveryService, - InspectionReportReviewService, ], exports: [InspectionReportsService], }) From f184c73c9e46ba4f0d4b9e310b95108d145279b1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:45:58 -0300 Subject: [PATCH 011/224] refactor(f4): remove Director report review endpoints --- .../inspection-report-review.controller.ts | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 api-v3/src/inspection-reports/inspection-report-review.controller.ts diff --git a/api-v3/src/inspection-reports/inspection-report-review.controller.ts b/api-v3/src/inspection-reports/inspection-report-review.controller.ts deleted file mode 100644 index 0e397bd..0000000 --- a/api-v3/src/inspection-reports/inspection-report-review.controller.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import type { Response } from 'express'; -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 { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; -import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; -import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; -import { - MAX_INSPECTION_REPORT_REVISION_BYTES, - type UploadedInspectionReportRevisionFile, -} from './inspection-report-revision-file'; -import { - InspectionReportReviewService, - type InspectionReportReviewView, -} from './inspection-report-review.service'; - -@Controller('inspection-reports/:reportId/review') -export class InspectionReportReviewController { - constructor(private readonly review: InspectionReportReviewService) {} - - @Get() - @RequirePermissions('inspection_reports.read') - get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise { - return this.review.get(reportId); - } - - @Post('revisions') - @RequirePermissions('inspection_reports.revise') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } })) - revision( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: CreateInspectionReportRevisionDto, - @UploadedFile() file: UploadedInspectionReportRevisionFile | undefined, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.createRevision(reportId, dto, file, principal, request); - } - - @Post('approve') - @RequirePermissions('inspection_reports.review') - approve( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: ApproveInspectionReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.approve(reportId, dto, principal, request); - } - - @Post('sign-final') - @RequirePermissions('inspection_reports.sign_final') - signFinal( - @Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string, - @Body() dto: SignFinalInspectionReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ): Promise { - return this.review.signFinal(reportId, dto, principal, request); - } -} - -@Controller('inspection-report-revisions') -export class InspectionReportRevisionContentController { - constructor(private readonly review: InspectionReportReviewService) {} - - @Get(':revisionId/content') - @RequirePermissions('inspection_reports.read') - async content( - @Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string, - @Res() response: Response, - ): Promise { - const content = await this.review.revisionContent(revisionId); - response.setHeader('Content-Type', content.mimeType); - response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`); - response.setHeader('Cache-Control', 'private, no-store'); - response.setHeader('X-Content-Type-Options', 'nosniff'); - await new Promise((resolveSend, rejectSend) => { - response.sendFile(content.filePath, (error) => { - if (error) rejectSend(error); - else resolveSend(); - }); - }); - } -} From c2d4f82a3a36711e221676423832e25e6a2001aa Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:46:03 -0300 Subject: [PATCH 012/224] refactor(f4): remove Director report review service --- .../inspection-report-review.service.ts | 433 ------------------ 1 file changed, 433 deletions(-) delete mode 100644 api-v3/src/inspection-reports/inspection-report-review.service.ts diff --git a/api-v3/src/inspection-reports/inspection-report-review.service.ts b/api-v3/src/inspection-reports/inspection-report-review.service.ts deleted file mode 100644 index c1991fd..0000000 --- a/api-v3/src/inspection-reports/inspection-report-review.service.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { isAbsolute, parse, resolve } from 'node:path'; -import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DataSource, EntityManager } from 'typeorm'; -import { administrationAuditContext } from '../administration/common/administration-audit'; -import { AuditService } from '../audit/audit.service'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { AuditAction } from '../database/entities'; -import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; -import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto'; -import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto'; -import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto'; -import { - INSPECTION_REPORT_WORD_MIME, - inspectInspectionReportRevisionUpload, - type UploadedInspectionReportRevisionFile, - validateInspectionReportRevisionContainer, -} from './inspection-report-revision-file'; -import { InspectionReportWordService } from './inspection-report-word.service'; - -export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; -export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD'; - -export interface InspectionReportReviewPerson { - id: string; - username: string; - firstName: string; - lastName: string; -} - -export interface InspectionReportRevisionView { - id: string; - reportId: string; - revisionNumber: number; - source: InspectionReportRevisionSource; - originalName: string; - mimeType: string; - sizeBytes: number; - sha256: string; - changeSummary: string | null; - createdAt: Date; - createdBy: InspectionReportReviewPerson; -} - -export interface InspectionReportSignatureView { - id: string; - revisionId: string; - signedAt: Date; - confirmationText: string; - signatureSha256: string; - signedBy: InspectionReportReviewPerson; -} - -export interface InspectionReportReviewView { - reportId: string; - reportCode: string; - reviewStatus: InspectionReportReviewStatus; - currentRevisionNumber: number; - approvedRevisionId: string | null; - approvedAt: Date | null; - reviewNote: string | null; - approvedBy: InspectionReportReviewPerson | null; - signature: InspectionReportSignatureView | null; - revisions: InspectionReportRevisionView[]; -} - -interface LockedReport { - id: string; - code: string; - status: string; - frozenSha256: string; - reviewStatus: InspectionReportReviewStatus; - currentRevisionNumber: number; - approvedRevisionId: string | null; - approvedAt: Date | null; - reviewNote: string | null; -} - -interface StoredRevisionRow { - id: string; - reportId: string; - revisionNumber: number; - source: InspectionReportRevisionSource; - originalName: string; - storedName: string; - mimeType: string; - sizeBytes: number; - sha256: string; -} - -const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.'; - -@Injectable() -export class InspectionReportReviewService { - private readonly revisionRoot: string; - - constructor( - private readonly dataSource: DataSource, - private readonly audit: AuditService, - private readonly word: InspectionReportWordService, - config: ConfigService, - ) { - const configured = config.get('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions'; - if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path'); - this.revisionRoot = resolve(configured); - if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root'); - } - - async get(reportId: string): Promise { - await this.word.ensure(reportId); - await this.ensureAutomaticRevision(reportId); - return this.loadView(reportId); - } - - async createRevision( - reportId: string, - dto: CreateInspectionReportRevisionDto, - file: UploadedInspectionReportRevisionFile | undefined, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - inspectInspectionReportRevisionUpload(file); - await this.assertDirector(principal.userId, this.dataSource.manager); - await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 }); - const storedName = `${reportId}-${randomUUID()}.docx`; - const filePath = resolve(this.revisionRoot, storedName); - if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); - await writeFile(filePath, file!.buffer, { mode: 0o600 }); - try { - await validateInspectionReportRevisionContainer(filePath); - const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); - const created = await this.dataSource.transaction(async (manager) => { - const report = await this.lockReport(manager, reportId); - this.assertOpenForReview(report); - const nextRevision = report.currentRevisionNumber + 1; - const [row] = await manager.query(` - INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by - ) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9) - RETURNING id - `, [ - report.id, - nextRevision, - this.cleanOriginalName(file!.originalname), - storedName, - INSPECTION_REPORT_WORD_MIME, - file!.buffer.length, - sha256, - dto.changeSummary.trim(), - principal.userId, - ]) as Array<{ id: string }>; - await manager.query(` - UPDATE inspection_reports - SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, nextRevision]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_REVISION_ADDED, - entityType: 'inspection_report_revision', - entityId: row.id, - afterData: { - reportId: report.id, - reportCode: report.code, - revisionNumber: nextRevision, - sha256, - source: 'DIRECTOR_UPLOAD', - }, - }, manager); - return row.id; - }); - if (!created) throw this.storageError(); - } catch (error) { - await rm(filePath, { force: true }).catch(() => undefined); - throw error; - } - return this.loadView(reportId); - } - - async approve( - reportId: string, - dto: ApproveInspectionReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - await this.dataSource.transaction(async (manager) => { - await this.assertDirector(principal.userId, manager); - const report = await this.lockReport(manager, reportId); - this.assertOpenForReview(report); - const [revision] = await manager.query(` - SELECT id, revision_number AS "revisionNumber", sha256 - FROM inspection_report_revisions - WHERE report_id = $1 AND revision_number = $2 - `, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>; - if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' }); - const approvedAt = new Date(); - await manager.query(` - UPDATE inspection_reports - SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3, - approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_APPROVED, - entityType: 'inspection_report', - entityId: report.id, - afterData: { - reportCode: report.code, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - approvedAt: approvedAt.toISOString(), - }, - }, manager); - }); - return this.loadView(reportId); - } - - async signFinal( - reportId: string, - dto: SignFinalInspectionReportDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' }); - await this.dataSource.transaction(async (manager) => { - await this.assertDirector(principal.userId, manager); - const report = await this.lockReport(manager, reportId); - if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' }); - if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) { - throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' }); - } - const [revision] = await manager.query(` - SELECT id, revision_number AS "revisionNumber", sha256 - FROM inspection_report_revisions - WHERE id = $1 AND report_id = $2 - `, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>; - if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' }); - const signedAt = new Date(); - const payload = { - schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1', - reportId: report.id, - reportCode: report.code, - reportFrozenSha256: report.frozenSha256, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - signedBy: principal.userId, - signedByUsername: principal.username, - signedAt: signedAt.toISOString(), - confirmationText: FINAL_CONFIRMATION_TEXT, - }; - const signatureSha256 = sha256CanonicalJson(payload); - const [signature] = await manager.query(` - INSERT INTO inspection_report_signatures ( - report_id, revision_id, signed_by, signed_at, confirmation_text, - signature_payload, signature_sha256 - ) VALUES ($1,$2,$3,$4,$5,$6,$7) - RETURNING id - `, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>; - await manager.query(` - UPDATE inspection_reports - SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - `, [report.id, signedAt]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_SIGNED, - entityType: 'inspection_report_signature', - entityId: signature.id, - afterData: { - reportId: report.id, - reportCode: report.code, - revisionId: revision.id, - revisionNumber: revision.revisionNumber, - revisionSha256: revision.sha256, - signatureSha256, - signedAt: signedAt.toISOString(), - }, - }, manager); - }); - return this.loadView(reportId); - } - - async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { - const [revision] = await this.dataSource.query(` - SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source, - original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType", - size_bytes::integer AS "sizeBytes", sha256 - FROM inspection_report_revisions WHERE id = $1 - `, [revisionId]) as StoredRevisionRow[]; - if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' }); - if (revision.source === 'AUTO') return this.word.content(revision.reportId); - const filePath = resolve(this.revisionRoot, revision.storedName); - if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError(); - const fileStat = await stat(filePath).catch(() => null); - if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError(); - const buffer = await readFile(filePath); - const sha256 = createHash('sha256').update(buffer).digest('hex'); - if (sha256 !== revision.sha256) throw this.storageError(); - return { filePath, originalName: revision.originalName, mimeType: revision.mimeType }; - } - - private async ensureAutomaticRevision(reportId: string): Promise { - await this.dataSource.query(` - INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by, created_at - ) - SELECT - id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type, - word_size_bytes, word_sha256, 'Versión automática inicial', generated_by, - COALESCE(word_generated_at, generated_at) - FROM inspection_reports - WHERE id = $1 - AND word_status = 'READY' - AND word_original_name IS NOT NULL - AND word_stored_name IS NOT NULL - AND word_mime_type = $2 - AND word_size_bytes > 0 - AND word_sha256 ~ '^[0-9a-f]{64}$' - ON CONFLICT (report_id, revision_number) DO NOTHING - `, [reportId, INSPECTION_REPORT_WORD_MIME]); - await this.dataSource.query(` - UPDATE inspection_reports - SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1) - `, [reportId]); - } - - private async loadView(reportId: string): Promise { - const [report] = await this.dataSource.query(` - SELECT - report.id AS "reportId", - report.code AS "reportCode", - report.review_status AS "reviewStatus", - report.current_revision_number AS "currentRevisionNumber", - report.approved_revision_id AS "approvedRevisionId", - report.approved_at AS "approvedAt", - report.review_note AS "reviewNote", - CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name - ) END AS "approvedBy" - FROM inspection_reports report - LEFT JOIN users approver ON approver.id = report.approved_by - WHERE report.id = $1 - `, [reportId]) as Array>; - if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); - const revisions = await this.dataSource.query(` - SELECT - revision.id, - revision.report_id AS "reportId", - revision.revision_number AS "revisionNumber", - revision.source, - revision.original_name AS "originalName", - revision.mime_type AS "mimeType", - revision.size_bytes::integer AS "sizeBytes", - revision.sha256, - revision.change_summary AS "changeSummary", - revision.created_at AS "createdAt", - JSONB_BUILD_OBJECT( - 'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name - ) AS "createdBy" - FROM inspection_report_revisions revision - JOIN users creator ON creator.id = revision.created_by - WHERE revision.report_id = $1 - ORDER BY revision.revision_number DESC - `, [reportId]) as InspectionReportRevisionView[]; - const [signature] = await this.dataSource.query(` - SELECT - signature.id, - signature.revision_id AS "revisionId", - signature.signed_at AS "signedAt", - signature.confirmation_text AS "confirmationText", - signature.signature_sha256 AS "signatureSha256", - JSONB_BUILD_OBJECT( - 'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name - ) AS "signedBy" - FROM inspection_report_signatures signature - JOIN users signer ON signer.id = signature.signed_by - WHERE signature.report_id = $1 - `, [reportId]) as InspectionReportSignatureView[]; - return { ...report, signature: signature ?? null, revisions }; - } - - private async lockReport(manager: EntityManager, reportId: string): Promise { - const [report] = await manager.query(` - SELECT - id, code, status, frozen_sha256 AS "frozenSha256", - review_status AS "reviewStatus", - current_revision_number AS "currentRevisionNumber", - approved_revision_id AS "approvedRevisionId", - approved_at AS "approvedAt", - review_note AS "reviewNote" - FROM inspection_reports - WHERE id = $1 - FOR UPDATE - `, [reportId]) as LockedReport[]; - if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); - if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' }); - return report; - } - - private assertOpenForReview(report: LockedReport): void { - if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' }); - if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' }); - } - - private async assertDirector(userId: string, manager: EntityManager): Promise { - const [role] = await manager.query(` - SELECT role.code - FROM user_roles membership - JOIN roles role ON role.id = membership.role_id - WHERE membership.user_id = $1 AND role.code = 'director' - LIMIT 1 - `, [userId]) as Array<{ code: string }>; - if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' }); - } - - private cleanOriginalName(value: string): string { - const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim(); - return (cleaned || 'informe-corregido.docx').slice(0, 255); - } - - private storageError(): InternalServerErrorException { - return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' }); - } -} From 6cc687d5e17fa0660ddaf1e5a2a21a8e7fc8d451 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:46:17 -0300 Subject: [PATCH 013/224] refactor(f4): remove survey runtime modules --- api-v3/src/app.module.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts index ed63e7f..6d0eacb 100644 --- a/api-v3/src/app.module.ts +++ b/api-v3/src/app.module.ts @@ -15,8 +15,6 @@ import { HealthController } from './health.controller'; import { HealthService } from './health.service'; import { DashboardModule } from './dashboard/dashboard.module'; import { AssetMasterModule } from './asset-master/asset-master.module'; -import { SurveyPlanningModule } from './survey-planning/survey-planning.module'; -import { SurveyExecutionModule } from './survey-execution/survey-execution.module'; import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module'; import { InspectionActsModule } from './inspection-acts/inspection-acts.module'; import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module'; @@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string { AdministrationModule, DashboardModule, AssetMasterModule, - SurveyPlanningModule, - SurveyExecutionModule, InspectionVisitsModule, InspectionActsModule, InspectionFindingsModule, From 92240b21c2d7be99c41e31dbf63d473df286f7ea Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:47:33 -0300 Subject: [PATCH 014/224] feat(f4): adopt institutional INSP numbering --- .../inspection-visit-code.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/api-v3/src/inspection-visits/inspection-visit-code.ts b/api-v3/src/inspection-visits/inspection-visit-code.ts index 3b48b52..fa946bd 100644 --- a/api-v3/src/inspection-visits/inspection-visit-code.ts +++ b/api-v3/src/inspection-visits/inspection-visit-code.ts @@ -4,12 +4,14 @@ export async function nextInspectionVisitCode( manager: EntityManager, plannedStartAt: Date, ): Promise { - const [yearRow] = (await manager.query(` - SELECT EXTRACT( - YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza' - )::integer AS year - `, [plannedStartAt])) as Array<{ year: number }>; - const year = Number(yearRow?.year); + const [dateRow] = (await manager.query(` + SELECT + EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD') AS day, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'MM') AS month, + TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'YY') AS short_year + `, [plannedStartAt])) as Array<{ year: number; day: string; month: string; short_year: string }>; + const year = Number(dateRow?.year); if (!Number.isInteger(year) || year < 2000 || year > 9999) { throw new Error('Unable to derive inspection visit year'); } @@ -20,15 +22,15 @@ export async function nextInspectionVisitCode( ); const [sequenceRow] = (await manager.query(` - SELECT COALESCE(MAX(RIGHT(code, 6)::integer), 0)::integer AS sequence + SELECT COALESCE(MAX(SUBSTRING(code FROM 6 FOR 5)::integer), 0)::integer AS sequence FROM inspection_visits - WHERE code LIKE $1 - AND code ~ ('^INS-' || $2::text || '-[0-9]{6}$') - `, [`INS-${year}-%`, year])) as Array<{ sequence: number }>; + WHERE code ~ '^INSP-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$' + AND RIGHT(code, 2) = $1 + `, [dateRow.short_year])) as Array<{ sequence: number }>; const sequence = Number(sequenceRow?.sequence ?? 0) + 1; - if (sequence > 999999) { + if (sequence > 99999) { throw new Error(`Inspection visit sequence exhausted for ${year}`); } - return `INS-${year}-${String(sequence).padStart(6, '0')}`; + return `INSP-${String(sequence).padStart(5, '0')}-${dateRow.day}-${dateRow.month}-${dateRow.short_year}`; } From bda4ce224d3c5f78b2ad8b2d8c1db23d27ffda65 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:49:41 -0300 Subject: [PATCH 015/224] feat(f4): require explicit act urgency --- api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts index 5ba1ee7..62c9566 100644 --- a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts @@ -4,6 +4,7 @@ import { ArrayMinSize, ArrayUnique, IsArray, + IsEnum, IsISO8601, IsOptional, IsString, @@ -11,11 +12,15 @@ import { MaxLength, MinLength, } from 'class-validator'; +import { InspectionActUrgency } from '../../database/entities'; export class CreateInspectionActDto { @IsISO8601({ strict: true }) occurredAt!: string; + @IsEnum(InspectionActUrgency) + urgency!: InspectionActUrgency; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) From 01efa2a8cc6e61801db164200348260de4fae0e1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:49:49 -0300 Subject: [PATCH 016/224] feat(f4): allow act urgency update while draft --- api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts index 7a780aa..af2be32 100644 --- a/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/update-inspection-act.dto.ts @@ -4,6 +4,7 @@ import { ArrayMinSize, ArrayUnique, IsArray, + IsEnum, IsISO8601, IsOptional, IsString, @@ -11,12 +12,17 @@ import { MaxLength, MinLength, } from 'class-validator'; +import { InspectionActUrgency } from '../../database/entities'; export class UpdateInspectionActDto { @IsOptional() @IsISO8601({ strict: true }) occurredAt?: string; + @IsOptional() + @IsEnum(InspectionActUrgency) + urgency?: InspectionActUrgency; + @IsOptional() @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() From e7a9ff7dd9a31759106db41abef2326c9b12d2bc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:51:11 -0300 Subject: [PATCH 017/224] feat(f4): align act numbering and urgency contract --- .../inspection-acts.service.ts | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index 5d89a31..260f858 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -14,6 +14,9 @@ import { DocumentSequenceType, InspectionAct, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, InspectionActVersionEvent, InspectionVisit, InspectionVisitStatus, @@ -72,6 +75,17 @@ export interface InspectionActListItem { title: string; summary: string; observations: string | null; + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; + deadlineAt: Date | null; + lockedAt: Date | null; + lockedBy: string | null; + lockedSha256: string | null; + sealedAt: Date | null; + sealedBy: string | null; currentVersion: number; cancellationReason: string | null; closedAt: Date | null; @@ -313,13 +327,16 @@ export class InspectionActsService { const occurredAt = new Date(dto.occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt); const actNumber = await this.allocateNumber(manager, actYear); - if (actNumber > 999999) { + if (actNumber > 99999) { throw new ConflictException({ code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED', message: 'La numeración anual de actas agotó su rango disponible', }); } - const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`; + const [dateRow] = (await manager.query(` + SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part + `, [occurredAt])) as Array<{ date_part: string }>; + const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`; const act = manager.getRepository(InspectionAct).create({ visitId, actYear, @@ -330,6 +347,17 @@ export class InspectionActsService { title: dto.title, summary: dto.summary, observations: dto.observations ?? null, + urgency: dto.urgency, + deadlineDays: null, + deadlineDayType: null, + deadlineBasis: null, + deadlineBaseAt: null, + deadlineAt: null, + lockedAt: null, + lockedBy: null, + lockedSha256: null, + sealedAt: null, + sealedBy: null, currentVersion: 0, cancellationReason: null, createdBy: principal.userId, @@ -384,6 +412,7 @@ export class InspectionActsService { await this.assertVisitAssets(manager, visit.id, nextAssetIds); const before = await this.loadView(manager, id); if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; + if (dto.urgency !== undefined) act.urgency = dto.urgency; if (dto.title !== undefined) act.title = dto.title; if (dto.summary !== undefined) act.summary = dto.summary; if (dto.observations !== undefined) act.observations = dto.observations; @@ -474,6 +503,17 @@ export class InspectionActsService { act.title, act.summary, act.observations, + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_by AS "lockedBy", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", + act.sealed_by AS "sealedBy", act.current_version AS "currentVersion", act.cancellation_reason AS "cancellationReason", act.closed_at AS "closedAt", @@ -645,7 +685,7 @@ export class InspectionActsService { if (rows.length > 0) { throw new ConflictException({ code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', - message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra', + message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra', }); } } @@ -665,7 +705,7 @@ export class InspectionActsService { if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { throw new BadRequestException({ code: 'INSPECTION_ACT_ASSET_INVALID', - message: 'Cada activo del acta debe estar incluido en la visita', + message: 'Cada inventario del acta debe estar incluido en la inspección', }); } } @@ -758,6 +798,15 @@ export class InspectionActsService { 'title', act.title, 'summary', act.summary, 'observations', act.observations, + 'urgency', act.urgency, + 'deadlineDays', act.deadline_days, + 'deadlineDayType', act.deadline_day_type, + 'deadlineBasis', act.deadline_basis, + 'deadlineBaseAt', act.deadline_base_at, + 'deadlineAt', act.deadline_at, + 'lockedAt', act.locked_at, + 'lockedSha256', act.locked_sha256, + 'sealedAt', act.sealed_at, 'currentVersion', act.current_version, 'cancellationReason', act.cancellation_reason, 'createdBy', act.created_by, @@ -804,6 +853,15 @@ export class InspectionActsService { title: act.title, summary: act.summary, observations: act.observations, + urgency: act.urgency, + deadlineDays: act.deadlineDays, + deadlineDayType: act.deadlineDayType, + deadlineBasis: act.deadlineBasis, + deadlineBaseAt: act.deadlineBaseAt, + deadlineAt: act.deadlineAt, + lockedAt: act.lockedAt, + lockedSha256: act.lockedSha256, + sealedAt: act.sealedAt, currentVersion: act.currentVersion, cancellationReason: act.cancellationReason, closedAt: act.closedAt, From 56f9c4234beb2bd0db60d300e71b997a5dd5b5b0 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:52:29 -0300 Subject: [PATCH 018/224] feat(f4): add locked and sealed act version events --- api-v3/src/database/entities/inspection-act-version.entity.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api-v3/src/database/entities/inspection-act-version.entity.ts b/api-v3/src/database/entities/inspection-act-version.entity.ts index ba98320..5c72100 100644 --- a/api-v3/src/database/entities/inspection-act-version.entity.ts +++ b/api-v3/src/database/entities/inspection-act-version.entity.ts @@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; export enum InspectionActVersionEvent { CREATED = 'CREATED', UPDATED = 'UPDATED', + LOCKED = 'LOCKED', + SEALED = 'SEALED', READY = 'READY', REOPENED = 'REOPENED', CLOSED = 'CLOSED', From c348f3162c987a7e516978b2f2d7534d9c3d9b09 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:52:58 -0300 Subject: [PATCH 019/224] feat(f4): add lifecycle and document audit actions --- api-v3/src/database/entities/audit-event.entity.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/api-v3/src/database/entities/audit-event.entity.ts b/api-v3/src/database/entities/audit-event.entity.ts index f77d9b2..6fabafe 100644 --- a/api-v3/src/database/entities/audit-event.entity.ts +++ b/api-v3/src/database/entities/audit-event.entity.ts @@ -82,15 +82,24 @@ export enum AuditAction { INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED', INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED', INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED', + INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED', + INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED', INSPECTION_ACT_READY = 'INSPECTION_ACT_READY', INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED', INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED', INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED', INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED', + INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED', INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED', + INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED', + INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED', INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED', INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED', + INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED', + INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED', + SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED', + SMTP_TEST_SENT = 'SMTP_TEST_SENT', DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED', DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED', DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT', From f23a28e4380d36f1ec3b872d351206909966778e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:54:53 -0300 Subject: [PATCH 020/224] refactor(f4): replace READY/CLOSED lifecycle with LOCKED/SEALED --- .../inspection-closing.service.ts | 1093 +++++++---------- 1 file changed, 454 insertions(+), 639 deletions(-) diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index be3c131..8953f74 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -14,7 +14,6 @@ import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { AuditAction, InspectionAct, @@ -23,28 +22,32 @@ import { InspectionActSignerType, InspectionCompanySignatureManifestation, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, InspectionActUploadMode, InspectionActVersionEvent, InspectionResponsibleAttendanceStatus, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { sha256CanonicalJson } from './canonical-json'; 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 { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { inspectInspectionSignatureFile, type UploadedInspectionSignatureFile, } from './inspection-signature-file'; -const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; -const CONSENT_VERSION = 'D5-1'; -const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.'; -const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.'; +const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4'; +const CONSENT_VERSION = 'F4-1'; +const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; +const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; interface ResponsibleView { actId: string; @@ -114,12 +117,28 @@ interface ClosureRecord { closedBy: string | null; } +interface DeadlinePolicy { + urgentDays: number; + urgentDayType: InspectionDeadlineDayType; + nonUrgentDays: number; + nonUrgentDayType: InspectionDeadlineDayType; +} + export interface InspectionClosureView { act: { id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; + deadlineAt: Date | null; + lockedAt: Date | null; + lockedSha256: string | null; + sealedAt: Date | null; currentVersion: number; closedAt: Date | null; closedBy: string | null; @@ -208,17 +227,18 @@ export class InspectionClosingService { INSERT INTO inspection_act_responsibles ( act_id, attendance_status, full_name, document_type, document_number, position, email, phone, absence_reason, updated_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (act_id) DO UPDATE SET - attendance_status = EXCLUDED.attendance_status, - full_name = EXCLUDED.full_name, - document_type = EXCLUDED.document_type, - document_number = EXCLUDED.document_number, - position = EXCLUDED.position, - email = EXCLUDED.email, - phone = EXCLUDED.phone, - absence_reason = EXCLUDED.absence_reason, - updated_by = EXCLUDED.updated_by + attendance_status=EXCLUDED.attendance_status, + full_name=EXCLUDED.full_name, + document_type=EXCLUDED.document_type, + document_number=EXCLUDED.document_number, + position=EXCLUDED.position, + email=EXCLUDED.email, + phone=EXCLUDED.phone, + absence_reason=EXCLUDED.absence_reason, + updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP `, [ actId, dto.attendanceStatus, @@ -239,12 +259,13 @@ export class InspectionClosingService { entityId: actId, beforeData: before as unknown as Record | null, afterData: after as unknown as Record, - metadata: { actId, visitId: visit.id }, + metadata: { actId, inspectionId: visit.id }, }, manager); return this.loadView(manager, actId); }); } + /** Finaliza el contenido: desde este punto el Acta queda inmutable. */ async prepare( actId: string, principal: AuthPrincipal, @@ -256,157 +277,127 @@ export class InspectionClosingService { this.assertDraftInProgress(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); await this.requireResponsible(manager, actId); - const [count] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_findings - WHERE act_id = $1 AND status <> 'VOIDED' - `, [actId])) as Array<{ total: number }>; - const [verification] = (await manager.query(` - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed - FROM inspection_finding_verification_visits verification_link - WHERE verification_link.visit_id = $1 - `, [visit.id])) as Array<{ total: number; completed: number }>; - const verificationTotal = Number(verification?.total ?? 0); - const verificationCompleted = Number(verification?.completed ?? 0); - if (verificationTotal > 0 && verificationCompleted !== verificationTotal) { - throw new ConflictException({ - code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', - message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta', - }); - } - if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) { - throw new ConflictException({ - code: 'INSPECTION_ACT_FINDING_REQUIRED', - message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse', - }); - } - const signatures = await this.signatureCount(manager, actId); - if (signatures > 0) { + await this.assertVerificationResultsComplete(manager, visit.id); + if (await this.signatureCount(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', - message: 'El acta ya tiene firmas y no puede volver a prepararse', + message: 'El acta ya tiene constancias de firma y no puede volver a finalizarse', }); } + + const policy = await this.deadlinePolicy(manager); + const deadlineDays = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDays + : policy.nonUrgentDays; + const deadlineDayType = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDayType + : policy.nonUrgentDayType; + const deadlineBasis = act.urgency === InspectionActUrgency.URGENT + ? InspectionDeadlineBasis.ACT_DATE + : InspectionDeadlineBasis.GEDO_DATE; + const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT + ? act.occurredAt + : null; + const deadlineAt = deadlineBaseAt + ? await this.calculateDeadline(manager, deadlineBaseAt, deadlineDays, deadlineDayType) + : null; + const lockedAt = new Date(); + const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'READY', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET status='LOCKED', + deadline_days=$2, + deadline_day_type=$3, + deadline_basis=$4, + deadline_base_at=$5, + deadline_at=$6, + locked_at=$7, + locked_by=$8, + current_version=current_version+1, + updated_by=$8, + updated_at=$7 + WHERE id=$1 RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const preparedAt = new Date(); - const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt); - const preparedSha256 = sha256CanonicalJson(preparedSnapshot); - await manager.query(` - INSERT INTO inspection_act_closures ( - act_id, schema_version, prepared_snapshot, prepared_sha256, - prepared_at, prepared_by - ) VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (act_id) DO UPDATE SET - schema_version = EXCLUDED.schema_version, - prepared_snapshot = EXCLUDED.prepared_snapshot, - prepared_sha256 = EXCLUDED.prepared_sha256, - prepared_at = EXCLUDED.prepared_at, - prepared_by = EXCLUDED.prepared_by `, [ actId, - CLOSURE_SCHEMA_VERSION, - preparedSnapshot, - preparedSha256, - preparedAt, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, + lockedAt, principal.userId, - ]); + ])) as Array<{ versionNumber: number }>; + + const preparedSnapshot = await this.buildLockedSnapshot(manager, actId, lockedAt); + const preparedSha256 = sha256CanonicalJson(preparedSnapshot); + await manager.query(` + UPDATE inspection_acts + SET locked_sha256=$2 + WHERE id=$1 + `, [actId, preparedSha256]); + await manager.query(` + INSERT INTO inspection_act_closures ( + act_id,schema_version,prepared_snapshot,prepared_sha256,prepared_at,prepared_by + ) VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (act_id) DO UPDATE SET + schema_version=EXCLUDED.schema_version, + prepared_snapshot=EXCLUDED.prepared_snapshot, + prepared_sha256=EXCLUDED.prepared_sha256, + prepared_at=EXCLUDED.prepared_at, + prepared_by=EXCLUDED.prepared_by, + final_snapshot=NULL, + final_sha256=NULL, + device_closed_at=NULL, + server_closed_at=NULL, + upload_mode=NULL, + closed_by=NULL + `, [actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, lockedAt, principal.userId]); await manager.query(` INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.READY, + InspectionActVersionEvent.LOCKED, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_READY, + action: AuditAction.INSPECTION_ACT_LOCKED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.READY, - preparedSha256, - schemaVersion: CLOSURE_SCHEMA_VERSION, + status: InspectionActStatus.LOCKED, + lockedSha256: preparedSha256, + urgency: act.urgency, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, }, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, + metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } + /** + * F4 elimina la reapertura normal: Finalizar Acta significa inmovilizar su contenido. + * Una eventual rectificación administrativa debe ser otro acto auditado, no volver a DRAFT. + */ async reopen( - actId: string, - principal: AuthPrincipal, - request: RequestWithContext, + _actId: string, + _principal: AuthPrincipal, + _request: RequestWithContext, ): Promise { - assertMobileInspector(principal); - return this.dataSource.transaction(async (manager) => { - const { act, visit } = await this.lockContext(manager, actId); - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'Sólo puede reabrirse un acta preparada de una visita en curso', - }); - } - await this.assertActorAssigned(manager, visit.id, principal, true); - if (await this.signatureCount(manager, actId)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN', - message: 'El acta ya tiene firmas y no puede volver a borrador', - }); - } - const closure = await this.requireClosure(manager, actId); - const [updated] = (await manager.query(` - UPDATE inspection_acts - SET status = 'DRAFT', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const snapshot = { - actId, - actCode: act.code, - status: InspectionActStatus.DRAFT, - reopenedAt: new Date().toISOString(), - previousPreparedSha256: closure.preparedSha256, - }; - await manager.query(` - INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) - `, [ - actId, - Number(updated.versionNumber), - InspectionActVersionEvent.REOPENED, - snapshot, - principal.userId, - principal.username, - ]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_REOPENED, - entityType: 'inspection_act', - entityId: actId, - afterData: snapshot, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, - }, manager); - return this.loadView(manager, actId); + throw new ConflictException({ + code: 'INSPECTION_ACT_LOCK_IS_IMMUTABLE', + message: 'Un acta finalizada es inmutable y no puede volver a borrador', }); } @@ -418,17 +409,22 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const identity = { - signerType: InspectionActSignerType.INSPECTOR, - signerUserId: principal.userId, - signerName: `${principal.firstName} ${principal.lastName}`.trim(), - documentType: null, - documentNumber: null, - position: 'Inspector/a', - consentText: INSPECTOR_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, - } as const; - return this.createSignedSignature(actId, dto, file, identity, principal, request, true); + return this.createSignedSignature( + actId, + dto, + file, + { + signerType: InspectionActSignerType.INSPECTOR, + signerUserId: principal.userId, + signerName: `${principal.firstName} ${principal.lastName}`.trim(), + documentType: null, + documentNumber: null, + position: 'Inspector/a', + consentText: INSPECTOR_CONSENT, + }, + principal, + request, + ); } async signCompany( @@ -439,7 +435,7 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - return this.createSignedSignature(actId, dto, file, null, principal, request, false); + return this.createSignedSignature(actId, dto, file, null, principal, request); } async recordCompanyOutcome( @@ -451,40 +447,29 @@ export class InspectionClosingService { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForCompanyOutcome(act, visit); + this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const responsible = await this.requireResponsible(manager, actId); - if ( - dto.status === InspectionActSignatureStatus.REFUSED - && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT - ) { + if (dto.status === InspectionActSignatureStatus.REFUSED + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE', - message: 'La negativa a firmar requiere que el responsable esté presente', + message: 'La negativa a firmar requiere identificar al responsable que se negó', }); } - if ( - dto.status === InspectionActSignatureStatus.ABSENT - && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT - ) { - throw new BadRequestException({ - code: 'INSPECTION_COMPANY_ABSENCE_MISMATCH', - message: 'El resultado ausente requiere que el responsable se haya registrado como ausente', - }); - } - const closure = await this.requireClosure(manager, actId); if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } - const source = this.signatureSource(principal); + const closure = await this.requireClosure(manager, actId); const createdAt = new Date(); - const signerName = responsible.fullName ?? 'Responsable de la empresa no presente'; + const source = this.signatureSource(principal); + const signerName = responsible.fullName ?? 'Responsable no presente'; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, documentType: responsible.documentType, @@ -493,20 +478,17 @@ export class InspectionClosingService { status: dto.status, reason: dto.reason, source, - uploadedBy: principal.userId, + recordedBy: principal.userId, createdAt: createdAt.toISOString(), }; const signaturePayloadSha256 = sha256CanonicalJson(payload); const id = randomUUID(); await manager.query(` INSERT INTO inspection_act_signatures ( - id, act_id, signer_type, signer_user_id, signer_name, - document_type, document_number, position, status, reason, - source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at - ) VALUES ( - $1, $2, $3, NULL, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14 - ) + id,act_id,signer_type,signer_user_id,signer_name, + document_type,document_number,position,status,reason, + source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES ($1,$2,$3,NULL,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) `, [ id, actId, @@ -529,12 +511,16 @@ export class InspectionClosingService { entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); } + /** + * Endpoint legacy /close conservado durante la transición de contratos. + * En F4 su semántica es SELLAR, no volver editable ni cerrar la Inspección. + */ async close( actId: string, dto: CloseInspectionActDto, @@ -542,30 +528,42 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const closed = await this.dataSource.transaction(async (manager) => { + const sealed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForFinalClosure(act, visit); + this.assertLockedForManifestation(act); 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) => ( + const inspectorSigned = signatures.some((item) => item.signerType === InspectionActSignerType.INSPECTOR - && item.status === InspectionActSignatureStatus.SIGNED - ))) { + && item.status === InspectionActSignatureStatus.SIGNED, + ); + if (!inspectorSigned) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', - message: 'Se requiere al menos una firma de inspector para cerrar el acta', + message: 'Se requiere la firma del inspector para sellar el acta', }); } - if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) { + const companyOutcomes = signatures.filter((item) => + item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, + ); + if (companyOutcomes.length !== 1) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', - message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa', + message: 'Debe existir exactamente una firma, disidencia o negativa del responsable de la empresa', }); } - const serverClosedAt = new Date(); - const deviceClosedAt = new Date(dto.clientClosedAt); - if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + const companyOutcome = companyOutcomes[0]; + if (companyOutcome.status === InspectionActSignatureStatus.ABSENT) { + throw new ConflictException({ + code: 'INSPECTION_ACT_COMPANY_MANIFESTATION_PENDING', + message: 'La ausencia no reemplaza la firma o negativa; la manifestación de la empresa sigue pendiente', + }); + } + + const serverSealedAt = new Date(); + const deviceSealedAt = new Date(dto.clientClosedAt); + if (deviceSealedAt.getTime() > serverSealedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_ACT_INVALID_DEVICE_TIME', message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', @@ -601,84 +599,77 @@ export class InspectionClosingService { })); const finalSnapshot = { schemaVersion: CLOSURE_SCHEMA_VERSION, - preparedSha256: closure.preparedSha256, - preparedSnapshot: closure.preparedSnapshot, + lockedSha256: closure.preparedSha256, + lockedSnapshot: closure.preparedSnapshot, signatures: signatureSnapshot, - closure: { - deviceClosedAt: deviceClosedAt.toISOString(), - serverClosedAt: serverClosedAt.toISOString(), + seal: { + deviceSealedAt: deviceSealedAt.toISOString(), + serverSealedAt: serverSealedAt.toISOString(), uploadMode: dto.uploadMode, - closedBy: principal.userId, - closedByUsername: principal.username, + sealedBy: principal.userId, + sealedByUsername: principal.username, }, }; const finalSha256 = sha256CanonicalJson(finalSnapshot); await manager.query(` UPDATE inspection_act_closures - SET final_snapshot = $2, - final_sha256 = $3, - device_closed_at = $4, - server_closed_at = $5, - upload_mode = $6, - closed_by = $7 - WHERE act_id = $1 - `, [ - actId, - finalSnapshot, - finalSha256, - deviceClosedAt, - serverClosedAt, - dto.uploadMode, - principal.userId, - ]); + SET final_snapshot=$2, + final_sha256=$3, + device_closed_at=$4, + server_closed_at=$5, + upload_mode=$6, + closed_by=$7 + WHERE act_id=$1 + `, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'CLOSED', - closed_at = $2, - closed_by = $3, - closure_sha256 = $4, - current_version = current_version + 1, - updated_by = $3, - updated_at = $2 - WHERE id = $1 + SET status='SEALED', + sealed_at=$2, + sealed_by=$3, + closed_at=$2, + closed_by=$3, + closure_sha256=$4, + current_version=current_version+1, + updated_by=$3, + updated_at=$2 + WHERE id=$1 RETURNING current_version AS "versionNumber" - `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; + `, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; await manager.query(` INSERT INTO inspection_act_versions ( - act_id, version_number, event, snapshot, actor_user_id, actor_username - ) VALUES ($1, $2, $3, $4, $5, $6) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.CLOSED, + InspectionActVersionEvent.SEALED, finalSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_CLOSED, + action: AuditAction.INSPECTION_ACT_SEALED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.CLOSED, + status: InspectionActStatus.SEALED, closureSha256: finalSha256, - serverClosedAt, - uploadMode: dto.uploadMode, + sealedAt: serverSealedAt, }, metadata: { actId, - visitId: visit.id, + inspectionId: visit.id, versionNumber: Number(updated.versionNumber), - findingsRemainOpen: true, - visitRemainsIndependent: true, + findingsRemainIndependent: true, + inspectionRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); }); await this.reports.ensureWordForAct(actId); - return closed; + return sealed; } async signatureContent(signatureId: string): Promise<{ @@ -720,11 +711,9 @@ export class InspectionClosingService { documentNumber: null; position: string; consentText: string; - auditAction: AuditAction; }, principal: AuthPrincipal, request: RequestWithContext, - requireSelfAssignment: boolean, ): Promise { this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); const inspected = inspectInspectionSignatureFile(file); @@ -737,15 +726,14 @@ export class InspectionClosingService { try { return await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - if (fixedIdentity) this.assertReadyInProgress(act, visit); - else this.assertReadyForCompanyOutcome(act, visit); - await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); + this.assertLockedForManifestation(act); + await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new ConflictException({ code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT', - message: 'No puede registrarse firma de empresa cuando el responsable está ausente', + message: 'No puede registrarse firma de empresa si no se identificó al responsable presente', }); } const identity = fixedIdentity ?? { @@ -756,15 +744,14 @@ export class InspectionClosingService { documentNumber: responsible!.documentNumber, position: responsible!.position, consentText: COMPANY_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, }; if (identity.signerType === InspectionActSignerType.INSPECTOR) { - const [existing] = (await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2 - `, [actId, identity.signerUserId])) as unknown[]; - if (existing) { + const existing = await manager.query(` + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='INSPECTOR' AND signer_user_id=$2 + LIMIT 1 + `, [actId, identity.signerUserId]) as unknown[]; + if (existing.length) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED', message: 'El inspector actual ya firmó esta acta', @@ -773,9 +760,10 @@ export class InspectionClosingService { } else if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } + const source = this.signatureSource(principal); const signedAt = new Date(); const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; @@ -788,7 +776,7 @@ export class InspectionClosingService { : null; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: identity.signerType, signerUserId: identity.signerUserId, signerName: identity.signerName, @@ -814,23 +802,17 @@ export class InspectionClosingService { const signaturePayloadSha256 = sha256CanonicalJson(payload); await manager.query(` 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, - device_label, source, prepared_sha256, signature_payload_sha256, - uploaded_by, created_at + 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, + device_label,source,prepared_sha256,signature_payload_sha256, + uploaded_by,created_at ) 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, $27, - $28, $20 + $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,$27,$28,$20 ) `, [ id, @@ -864,16 +846,11 @@ export class InspectionClosingService { ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: identity.auditAction, + action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { - actId, - visitId: visit.id, - immutable: true, - signaturePayloadSha256, - }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); @@ -883,116 +860,50 @@ export class InspectionClosingService { } } - private async buildPreparedSnapshot( + private async buildLockedSnapshot( manager: EntityManager, actId: string, - preparedAt: Date, + lockedAt: Date, ): Promise> { - const [act] = (await manager.query(` - SELECT - act.id, - act.code, - act.act_year AS "actYear", - act.act_number AS "actNumber", - act.status, - act.occurred_at AS "occurredAt", - act.title, - act.summary, - act.observations, - act.current_version AS "currentVersion", - act.created_at AS "createdAt", - act.updated_at AS "updatedAt", - JSONB_BUILD_OBJECT( - 'id', visit.id, - 'code', visit.code, - 'title', visit.title, - 'objective', visit.objective, - 'status', visit.status, - 'scopeAssetId', visit.scope_asset_id, - 'leadInspectorUserId', visit.lead_inspector_user_id, - 'plannedStartAt', visit.planned_start_at, - 'plannedEndAt', visit.planned_end_at, - 'actualStartedAt', visit.actual_started_at, - 'instructions', visit.instructions - ) AS visit + const [act] = await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id',act.id, + 'code',act.code, + 'status',act.status, + 'occurredAt',act.occurred_at, + 'title',act.title, + 'summary',act.summary, + 'observations',act.observations, + 'urgency',act.urgency, + 'deadlineDays',act.deadline_days, + 'deadlineDayType',act.deadline_day_type, + 'deadlineBasis',act.deadline_basis, + 'deadlineBaseAt',act.deadline_base_at, + 'deadlineAt',act.deadline_at, + 'inspection',JSONB_BUILD_OBJECT( + 'id',visit.id, + 'code',visit.code, + 'status',visit.status, + 'operationalAreaId',visit.operational_area_id, + 'operatorCompanyId',visit.operator_company_id, + 'leadInspectorUserId',visit.lead_inspector_user_id, + 'actualStartedAt',visit.actual_started_at + ) + ) AS act FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array>; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ act: Record }>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); - const team = await manager.query(` - SELECT - member.user_id AS "userId", - user_account.username, - user_account.first_name AS "firstName", - user_account.last_name AS "lastName", - user_account.email, - (visit.lead_inspector_user_id = member.user_id) AS "isLead" - FROM inspection_visit_members member - INNER JOIN inspection_visits visit ON visit.id = member.visit_id - INNER JOIN users user_account ON user_account.id = member.user_id - WHERE member.visit_id = $1 AND member.included = true - ORDER BY "isLead" DESC, user_account.username, member.user_id - `, [(act.visit as { id: string }).id]) as Array>; - const assets = await manager.query(` - SELECT - asset.id, - asset.code, - asset.name, - asset.common_name AS "commonName", - asset.description, - asset.parent_id AS "parentId", - CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name, - 'commonName', company.common_name - ) END AS "operatorCompany", - CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name, - 'commonName', area.common_name - ) END AS "operationalArea", - asset.information_status AS "informationStatus", - asset.current_version AS "currentVersion", - asset.data_origin AS "dataOrigin", - asset.source_name AS "sourceName", - asset.source_reference AS "sourceReference", - asset.source_observed_at AS "sourceObservedAt", - asset_type.id AS "typeId", - asset_type.code AS "typeCode", - asset_type.name AS "typeName", - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'definitionId', definition.id, - 'code', definition.code, - 'name', definition.name, - 'dataType', definition.data_type, - 'value', attribute_value.value - ) ORDER BY definition.sort_order, definition.code, definition.id) - FROM asset_attribute_values attribute_value - INNER JOIN asset_attribute_definitions definition - ON definition.id = attribute_value.definition_id - WHERE attribute_value.asset_id = asset.id - ), '[]'::jsonb) AS attributes, - CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'type', geometry.geometry_type, - 'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb, - 'source', geometry.source, - 'accuracyM', geometry.accuracy_m, - 'capturedAt', geometry.captured_at, - 'deviceLabel', geometry.device_label - ) END AS geometry + const inventories = await manager.query(` + SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", + type.code AS "typeCode",type.name AS "typeName" FROM inspection_act_assets link - INNER JOIN assets asset ON asset.id = link.asset_id - INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id - LEFT JOIN assets area ON area.id = asset.operational_area_id - LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id - WHERE link.act_id = $1 AND link.included = true - ORDER BY asset.code, asset.id + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE link.act_id=$1 AND link.included=true + ORDER BY asset.code,asset.id `, [actId]) as Array>; const findings = await manager.query(` SELECT @@ -1005,159 +916,129 @@ export class InspectionClosingService { finding.title, finding.description, finding.legal_basis AS "legalBasis", - finding.glossary, - finding.catalog_revision AS "catalogRevision", - finding.suggested_severity AS "suggestedSeverity", finding.severity, + finding.is_recurrence AS "isRecurrence", + finding.recurrence_of_finding_id AS "recurrenceOfFindingId", finding.correction_due_on AS "correctionDueOn", - finding.company_response AS "companyResponse", - finding.company_response_received_on AS "companyResponseReceivedOn", - finding.company_committed_correction_on AS "companyCommittedCorrectionOn", - finding.next_control_on AS "nextControlOn", - finding.current_version AS "currentVersion", - JSONB_BUILD_OBJECT( - 'code', catalog.code, - 'sourceNumber', catalog.source_number, - 'title', catalog.title, - 'revision', catalog.revision, - 'suggestedSeverity', finding.suggested_severity, - 'categoryCode', category.code, - 'categoryName', category.name - ) AS catalog, - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', evidence.id, - 'communicationId', evidence.communication_id, - 'kind', evidence.kind, - 'purpose', evidence.purpose, - 'originalName', evidence.original_name, - 'mimeType', evidence.mime_type, - 'sizeBytes', evidence.size_bytes, - 'sha256', evidence.sha256, - 'title', evidence.title, - 'description', evidence.description, - 'capturedAt', evidence.captured_at, - 'latitude', evidence.latitude, - 'longitude', evidence.longitude, - 'accuracyM', evidence.accuracy_m, - 'deviceLabel', evidence.device_label, - 'source', evidence.source, - 'uploadedBy', evidence.uploaded_by, - 'createdAt', evidence.created_at - ) ORDER BY evidence.created_at, evidence.id) - FROM inspection_finding_evidence evidence - WHERE evidence.finding_id = finding.id - ), '[]'::jsonb) AS evidence, - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', communication.id, - 'direction', communication.direction, - 'channel', communication.channel, - 'type', communication.type, - 'occurredAt', communication.occurred_at, - 'subject', communication.subject, - 'details', communication.details, - 'contactName', communication.contact_name, - 'contactEmail', communication.contact_email, - 'createdBy', communication.created_by, - 'createdAt', communication.created_at - ) ORDER BY communication.occurred_at, communication.id) - FROM inspection_finding_communications communication - WHERE communication.finding_id = finding.id - ), '[]'::jsonb) AS communications + finding.current_version AS "currentVersion" 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 - WHERE finding.act_id = $1 AND finding.status <> 'VOIDED' - ORDER BY finding.finding_number, finding.id + WHERE finding.act_id=$1 AND finding.status<>'VOIDED' + ORDER BY finding.finding_number,finding.id `, [actId]) as Array>; - const verificationResults = await manager.query(` - SELECT - verification_link.finding_id AS "findingId", - finding.code AS "findingCode", - finding.title AS "findingTitle", - finding.description AS "findingDescription", - finding.asset_id AS "assetId", - asset.code AS "assetCode", - asset.name AS "assetName", - verification_link.target_control_on AS "targetControlOn", - verification_link.outcome, - verification_link.result_notes AS "resultNotes", - verification_link.verified_at AS "verifiedAt", - verification_link.result_recorded_at AS "resultRecordedAt", - verification_link.rescheduled_control_on AS "rescheduledControlOn", - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', evidence.id, - 'kind', evidence.kind, - 'purpose', evidence.purpose, - 'originalName', evidence.original_name, - 'mimeType', evidence.mime_type, - 'sizeBytes', evidence.size_bytes, - 'sha256', evidence.sha256, - 'title', evidence.title, - 'description', evidence.description, - 'capturedAt', evidence.captured_at, - 'latitude', evidence.latitude, - 'longitude', evidence.longitude, - 'accuracyM', evidence.accuracy_m, - 'deviceLabel', evidence.device_label, - 'source', evidence.source, - 'uploadedBy', evidence.uploaded_by, - 'createdAt', evidence.created_at - ) ORDER BY evidence.created_at, evidence.id) - FROM inspection_finding_evidence evidence - WHERE evidence.finding_id = finding.id - AND evidence.verification_visit_id = $1 - AND evidence.purpose = 'VERIFICATION' - ), '[]'::jsonb) AS evidence - FROM inspection_finding_verification_visits verification_link - INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id - INNER JOIN assets asset ON asset.id = finding.asset_id - WHERE verification_link.visit_id = $1 - ORDER BY finding.code - `, [(act.visit as { id: string }).id]) as Array>; return { schemaVersion: CLOSURE_SCHEMA_VERSION, - preparedAt: preparedAt.toISOString(), - act, + lockedAt: lockedAt.toISOString(), + act: act.act, responsible, - team, - assets, + inventories, findings, - verificationResults, }; } - private async loadView(manager: EntityManager, actId: string): Promise { - const [context] = (await manager.query(` + private async assertVerificationResultsComplete(manager: EntityManager, inspectionId: string): Promise { + const [row] = await manager.query(` SELECT - act.id, - act.code, - act.status, - act.visit_id AS "visitId", + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed + FROM inspection_finding_verification_visits + WHERE visit_id=$1 + `, [inspectionId]) as Array<{ total: number; completed: number }>; + if (Number(row?.total ?? 0) > 0 && Number(row.completed) !== Number(row.total)) { + throw new ConflictException({ + code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', + message: 'Registrá el resultado de todas las verificaciones antes de finalizar el acta', + }); + } + } + + private async deadlinePolicy(manager: EntityManager): Promise { + const [row] = await manager.query(` + SELECT + urgent_days AS "urgentDays", + urgent_day_type AS "urgentDayType", + non_urgent_days AS "nonUrgentDays", + non_urgent_day_type AS "nonUrgentDayType" + FROM inspection_deadline_policies + ORDER BY created_at + LIMIT 1 + `) as DeadlinePolicy[]; + return row ?? { + urgentDays: 5, + urgentDayType: InspectionDeadlineDayType.BUSINESS, + nonUrgentDays: 10, + nonUrgentDayType: InspectionDeadlineDayType.BUSINESS, + }; + } + + private async calculateDeadline( + manager: EntityManager, + baseAt: Date, + days: number, + dayType: InspectionDeadlineDayType, + ): Promise { + if (dayType === InspectionDeadlineDayType.CALENDAR) { + const [row] = await manager.query(` + SELECT ( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + + time '23:59:59' + ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at + `, [baseAt, days]) as Array<{ due_at: Date }>; + return row.due_at; + } + const [row] = await manager.query(` + WITH candidates AS ( + SELECT + day::date AS day, + COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business + FROM generate_series( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, + interval '1 day' + ) day + LEFT JOIN inspection_business_calendar_days override ON override.date=day::date + ), ranked AS ( + SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position + FROM candidates + WHERE business=true + ) + SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at + FROM ranked + WHERE position=$2 + LIMIT 1 + `, [baseAt, days]) as Array<{ due_at: Date }>; + if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); + return row.due_at; + } + + private async loadView(manager: EntityManager, actId: string): Promise { + const [context] = await manager.query(` + SELECT + act.id,act.code,act.status,act.visit_id AS "visitId", + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", act.current_version AS "currentVersion", - act.closed_at AS "closedAt", - act.closed_by AS "closedBy", + act.closed_at AS "closedAt",act.closed_by AS "closedBy", act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.status AS "visitStatus", + visit.code AS "visitCode",visit.status AS "visitStatus", visit.actual_closed_at AS "visitActualClosedAt" FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array<{ - id: string; - code: string; - status: InspectionActStatus; - visitId: string; - currentVersion: number; - closedAt: Date | null; - closedBy: string | null; - closureSha256: string | null; - visitCode: string; - visitStatus: InspectionVisitStatus; - visitActualClosedAt: Date | null; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ + id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; + lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; + closedAt: Date | null; closedBy: string | null; closureSha256: string | null; + visitCode: string; visitStatus: InspectionVisitStatus; visitActualClosedAt: Date | null; }>; if (!context) throw actNotFound(); const responsible = await this.loadResponsible(manager, actId); @@ -1169,6 +1050,15 @@ export class InspectionClosingService { code: context.code, status: context.status, visitId: context.visitId, + urgency: context.urgency, + deadlineDays: context.deadlineDays, + deadlineDayType: context.deadlineDayType, + deadlineBasis: context.deadlineBasis, + deadlineBaseAt: context.deadlineBaseAt, + deadlineAt: context.deadlineAt, + lockedAt: context.lockedAt, + lockedSha256: context.lockedSha256, + sealedAt: context.sealedAt, currentVersion: Number(context.currentVersion), closedAt: context.closedAt, closedBy: context.closedBy, @@ -1191,8 +1081,7 @@ export class InspectionClosingService { serverClosedAt: closure.serverClosedAt, uploadMode: closure.uploadMode, closedBy: closure.closedBy, - isCurrent: context.status === InspectionActStatus.READY - || context.status === InspectionActStatus.CLOSED, + isCurrent: [InspectionActStatus.LOCKED, InspectionActStatus.SEALED].includes(context.status), } : null, signatures, consents: { @@ -1204,23 +1093,13 @@ export class InspectionClosingService { } private async loadResponsible(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT - act_id AS "actId", - attendance_status AS "attendanceStatus", - full_name AS "fullName", - document_type AS "documentType", - document_number AS "documentNumber", - position, - email, - phone, - absence_reason AS "absenceReason", - updated_by AS "updatedBy", - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM inspection_act_responsibles - WHERE act_id = $1 - `, [actId])) as ResponsibleView[]; + const [row] = await manager.query(` + SELECT act_id AS "actId",attendance_status AS "attendanceStatus", + full_name AS "fullName",document_type AS "documentType",document_number AS "documentNumber", + position,email,phone,absence_reason AS "absenceReason",updated_by AS "updatedBy", + created_at AS "createdAt",updated_at AS "updatedAt" + FROM inspection_act_responsibles WHERE act_id=$1 + `, [actId]) as ResponsibleView[]; return row ?? null; } @@ -1229,30 +1108,22 @@ export class InspectionClosingService { if (!responsible) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', - message: 'Debe identificarse al responsable presente o documentar su ausencia', + message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', }); } return responsible; } private async loadClosure(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT - act_id AS "actId", - schema_version AS "schemaVersion", - prepared_snapshot AS "preparedSnapshot", - prepared_sha256 AS "preparedSha256", - prepared_at AS "preparedAt", - prepared_by AS "preparedBy", - final_snapshot AS "finalSnapshot", - final_sha256 AS "finalSha256", - device_closed_at AS "deviceClosedAt", - server_closed_at AS "serverClosedAt", - upload_mode AS "uploadMode", - closed_by AS "closedBy" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as ClosureRecord[]; + const [row] = await manager.query(` + SELECT act_id AS "actId",schema_version AS "schemaVersion", + prepared_snapshot AS "preparedSnapshot",prepared_sha256 AS "preparedSha256", + prepared_at AS "preparedAt",prepared_by AS "preparedBy", + final_snapshot AS "finalSnapshot",final_sha256 AS "finalSha256", + device_closed_at AS "deviceClosedAt",server_closed_at AS "serverClosedAt", + upload_mode AS "uploadMode",closed_by AS "closedBy" + FROM inspection_act_closures WHERE act_id=$1 + `, [actId]) as ClosureRecord[]; return row ?? null; } @@ -1260,8 +1131,8 @@ export class InspectionClosingService { const closure = await this.loadClosure(manager, actId); if (!closure) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_PREPARED', - message: 'El acta todavía no fue preparada para firmas', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta todavía no fue finalizada y bloqueada', }); } return closure; @@ -1269,96 +1140,61 @@ export class InspectionClosingService { private signatureSelect(): string { return ` - SELECT - signature.id, - signature.act_id AS "actId", - signature.signer_type AS "signerType", - signature.signer_user_id AS "signerUserId", - signature.signer_name AS "signerName", - signature.document_type AS "documentType", - signature.document_number AS "documentNumber", - signature.position, - signature.status, - signature.reason, + SELECT signature.id,signature.act_id AS "actId",signature.signer_type AS "signerType", + signature.signer_user_id AS "signerUserId",signature.signer_name AS "signerName", + signature.document_type AS "documentType",signature.document_number AS "documentNumber", + 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", - signature.size_bytes::integer AS "sizeBytes", - signature.image_sha256 AS "imageSha256", - signature.consent_text AS "consentText", - signature.consent_version AS "consentVersion", - signature.consent_accepted_at AS "consentAcceptedAt", - signature.client_signed_at AS "clientSignedAt", - signature.signed_at AS "signedAt", - signature.latitude::double precision AS latitude, - signature.longitude::double precision AS longitude, - signature.accuracy_m::double precision AS "accuracyM", - signature.device_label AS "deviceLabel", - signature.source, - signature.prepared_sha256 AS "preparedSha256", + signature.original_name AS "originalName",signature.stored_name AS "storedName", + signature.mime_type AS "mimeType",signature.size_bytes::integer AS "sizeBytes", + signature.image_sha256 AS "imageSha256",signature.consent_text AS "consentText", + signature.consent_version AS "consentVersion",signature.consent_accepted_at AS "consentAcceptedAt", + signature.client_signed_at AS "clientSignedAt",signature.signed_at AS "signedAt", + signature.latitude::double precision AS latitude,signature.longitude::double precision AS longitude, + signature.accuracy_m::double precision AS "accuracyM",signature.device_label AS "deviceLabel", + signature.source,signature.prepared_sha256 AS "preparedSha256", signature.signature_payload_sha256 AS "signaturePayloadSha256", - signature.uploaded_by AS "uploadedBy", - uploader.username AS "uploadedByUsername", + signature.uploaded_by AS "uploadedBy",uploader.username AS "uploadedByUsername", signature.created_at AS "createdAt" FROM inspection_act_signatures signature - INNER JOIN users uploader ON uploader.id = signature.uploaded_by + JOIN users uploader ON uploader.id=signature.uploaded_by `; } private async loadSignatures(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - ${this.signatureSelect()} - WHERE signature.act_id = $1 - ORDER BY signature.signer_type, signature.created_at, signature.id + ${this.signatureSelect()} WHERE signature.act_id=$1 + ORDER BY signature.signer_type,signature.created_at,signature.id `, [actId]) as StoredSignature[]; return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); } private async loadStoredSignature(manager: EntityManager, id: string): Promise { - const [row] = await manager.query(` - ${this.signatureSelect()} - WHERE signature.id = $1 - `, [id]) as StoredSignature[]; + const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[]; if (!row) throw signatureNotFound(); return row; } private async signatureCount(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_act_signatures - WHERE act_id = $1 - `, [actId])) as Array<{ total: number }>; + const [row] = await manager.query(`SELECT COUNT(*)::integer AS total FROM inspection_act_signatures WHERE act_id=$1`, [actId]) as Array<{ total: number }>; return Number(row?.total ?? 0); } private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE' - LIMIT 1 + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE' LIMIT 1 `, [actId]) as unknown[]; return rows.length > 0; } - private async lockContext( - manager: EntityManager, - actId: string, - ): Promise<{ act: InspectionAct; visit: InspectionVisit }> { + private async lockContext(manager: EntityManager, actId: string): Promise<{ act: InspectionAct; visit: InspectionVisit }> { const act = await manager.getRepository(InspectionAct) - .createQueryBuilder('act') - .where('act.id = :actId', { actId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('act').where('act.id=:actId', { actId }).setLock('pessimistic_write').getOne(); if (!act) throw actNotFound(); const visit = await manager.getRepository(InspectionVisit) - .createQueryBuilder('visit') - .where('visit.id = :visitId', { visitId: act.visitId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('visit').where('visit.id=:visitId', { visitId: act.visitId }).setLock('pessimistic_write').getOne(); if (!visit) throw actNotFound(); return { act, visit }; } @@ -1367,56 +1203,35 @@ export class InspectionClosingService { if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_EDITABLE', - message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso', + message: 'El acta sólo puede editarse mientras está en borrador y la inspección está en curso', }); } } - private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + private assertLockedForManifestation(act: InspectionAct): void { + if (act.status !== InspectionActStatus.LOCKED) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'El acta debe estar preparada y la visita en curso', - }); - } - } - - 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', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta debe estar finalizada y bloqueada antes de firmarse o sellarse', }); } } private async assertActorAssigned( manager: EntityManager, - visitId: string, + inspectionId: string, principal: AuthPrincipal, allowManager: boolean, ): Promise { if (allowManager && principal.permissions.includes('inspections.manage')) return; const rows = await manager.query(` - SELECT 1 - FROM inspection_visit_members - WHERE visit_id = $1 AND user_id = $2 AND included = true - `, [visitId, principal.userId]) as unknown[]; - if (rows.length === 0) { + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true + `, [inspectionId, principal.userId]) as unknown[]; + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', - message: 'Sólo un integrante asignado a la visita puede realizar esta firma', + message: 'Sólo un inspector asignado puede realizar esta operación', }); } } From 97a6fb029dd65c6250d6ee1aa56cba7976aceeca Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:55:14 -0300 Subject: [PATCH 021/224] refactor(f4): expose lock and seal act lifecycle --- .../inspection-closing.controller.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/api-v3/src/inspection-closing/inspection-closing.controller.ts b/api-v3/src/inspection-closing/inspection-closing.controller.ts index 14e0663..ef36886 100644 --- a/api-v3/src/inspection-closing/inspection-closing.controller.ts +++ b/api-v3/src/inspection-closing/inspection-closing.controller.ts @@ -48,9 +48,9 @@ export class InspectionClosingController { return this.closing.upsertResponsible(actId, dto, principal, request); } - @Post('ready') + @Post('lock') @RequirePermissions('inspection_closure.prepare') - ready( + lock( @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, @@ -58,16 +58,6 @@ export class InspectionClosingController { return this.closing.prepare(actId, principal, request); } - @Post('reopen') - @RequirePermissions('inspection_closure.prepare') - reopen( - @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.closing.reopen(actId, principal, request); - } - @Post('signatures/inspector') @RequirePermissions('inspection_closure.sign') @UseInterceptors(FileInterceptor('file', { @@ -109,9 +99,9 @@ export class InspectionClosingController { return this.closing.recordCompanyOutcome(actId, dto, principal, request); } - @Post('close') + @Post('seal') @RequirePermissions('inspection_closure.close') - close( + seal( @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CloseInspectionActDto, @CurrentAuth() principal: AuthPrincipal, From 416aac335c0e99b40e2729e10a038e9359c8d17b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:56:11 -0300 Subject: [PATCH 022/224] refactor(f4): make INF editable and one-to-one with sealed Act --- .../inspection-reports.service.ts | 407 ++++++++---------- 1 file changed, 178 insertions(+), 229 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index 4d7d13f..83a7f36 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, - DocumentSequenceType, InspectionActStatus, InspectionReportPdfStatus, InspectionReportStatus, @@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; -const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; +const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4'; interface ContextAsset { id: string; @@ -32,20 +31,31 @@ export interface InspectionReportListItem { reportNumber: number; code: string; status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; + gedoPdfOriginalName: string | null; + gedoPdfSha256: string | null; pdfStatus: InspectionReportPdfStatus; wordStatus: 'PENDING' | 'READY' | 'FAILED'; wordGeneratedAt: Date | null; - reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; - currentRevisionNumber: number; - approvedAt: Date | null; - signedAt: Date | null; title: string; actVersion: number; actClosureSha256: string; frozenSha256: string; generatedAt: Date; generatedBy: { id: string; username: string; firstName: string; lastName: string }; - act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null }; + act: { + id: string; + code: string; + title: string; + status: string; + occurredAt: Date; + sealedAt: Date | null; + urgency: string; + deadlineAt: Date | null; + }; visit: { id: string; code: string; title: string; status: string }; companies: ContextAsset[]; areas: ContextAsset[]; @@ -63,7 +73,7 @@ export interface PendingInspectionReportItem { actTitle: string; actYear: number; occurredAt: Date; - closedAt: Date; + sealedAt: Date; closureSha256: string; visitCode: string; visitTitle: string; @@ -127,7 +137,7 @@ export class InspectionReportsService { async listPending(query: ListInspectionReportsQueryDto) { const conditions = [ - `act.status = 'CLOSED'`, + `act.status = 'SEALED'`, 'report.id IS NULL', 'act.closure_sha256 IS NOT NULL', ]; @@ -143,21 +153,6 @@ export class InspectionReportsService { OR act.title ILIKE ${search} OR visit.code ILIKE ${search} OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); @@ -196,7 +191,7 @@ export class InspectionReportsService { act.title AS "actTitle", act.act_year AS "actYear", act.occurred_at AS "occurredAt", - act.closed_at AS "closedAt", + act.sealed_at AS "sealedAt", act.closure_sha256 AS "closureSha256", visit.code AS "visitCode", visit.title AS "visitTitle", @@ -226,16 +221,7 @@ export class InspectionReportsService { } async get(id: string): Promise { - const [report] = (await this.dataSource.query( - `${this.reportSelect('WHERE report.id = $1')}`, - [id], - )) as InspectionReportListItem[]; - if (!report) throw reportNotFound(); - const [snapshot] = (await this.dataSource.query( - 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', - [id], - )) as Array<{ frozenSnapshot: Record }>; - return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; + return this.getWithManager(this.dataSource.manager, id); } async generate( @@ -244,144 +230,146 @@ export class InspectionReportsService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); + const report = await this.dataSource.transaction((manager) => + this.ensureFrozenReport(manager, actId, principal, request), + ); await this.word.ensure(report.id); return this.get(report.id); } + /** + * Conserva una copia interna inmutable del Acta sellada como fuente documental. + * El INF que se genera desde esa fuente permanece editable y versionable. + */ async ensureFrozenReport( manager: EntityManager, actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { - const [act] = (await manager.query(` - SELECT - act.id, - act.visit_id AS "visitId", - act.act_year AS "actYear", - act.code, - act.title, - act.status, - act.current_version AS "currentVersion", - act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.title AS "visitTitle" - FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - FOR UPDATE OF act - `, [actId])) as Array<{ - id: string; - visitId: string; - actYear: number; - code: string; - title: string; - status: InspectionActStatus; - currentVersion: number; - closureSha256: string | null; - visitCode: string; - visitTitle: string; - }>; - if (!act) throw actNotFound(); - if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_ACT_NOT_CLOSED', - message: 'El informe sólo puede emitirse después del cierre definitivo del acta', - }); - } - await this.assertActorAssigned(manager, act.visitId, principal.userId); - const [existing] = (await manager.query( - 'SELECT id FROM inspection_reports WHERE act_id = $1', - [actId], - )) as Array<{ id: string }>; - if (existing) return this.getWithManager(manager, existing.id); - const [closure] = (await manager.query(` - SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as Array<{ - finalSnapshot: Record | null; - finalSha256: string | null; - }>; - if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN', - message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe', - }); - } - const reportNumber = await this.allocateNumber(manager, act.actYear); - if (reportNumber > 999999) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED', - message: 'La numeración anual de informes agotó su rango disponible', - }); - } - const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`; - const generatedAt = new Date(); - const title = `Informe técnico · ${act.title}`.slice(0, 220); - const frozenSnapshot = { - schemaVersion: REPORT_SCHEMA_VERSION, - report: { - code, - reportYear: act.actYear, - reportNumber, - generatedAt: generatedAt.toISOString(), - generatedBy: principal.userId, - generatedByUsername: principal.username, - visitId: act.visitId, - visitCode: act.visitCode, - visitTitle: act.visitTitle, - actId: act.id, - actCode: act.code, - actVersion: act.currentVersion, - actClosureSha256: act.closureSha256, - }, - actClosure: closure.finalSnapshot, - }; - const frozenSha256 = sha256CanonicalJson(frozenSnapshot); - const [created] = (await manager.query(` - INSERT INTO inspection_reports ( - visit_id, act_id, report_year, report_number, code, status, pdf_status, - title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot, - generated_at, generated_by - ) VALUES ( - $1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12 - ) - RETURNING id - `, [ - act.visitId, + const [act] = (await manager.query(` + SELECT act.id, - act.actYear, - reportNumber, + act.visit_id AS "visitId", + act.act_year AS "actYear", + act.act_number AS "actNumber", + act.code, + act.title, + act.status, + act.current_version AS "currentVersion", + act.closure_sha256 AS "closureSha256", + act.occurred_at AS "occurredAt", + visit.code AS "visitCode", + visit.title AS "visitTitle" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + FOR UPDATE OF act + `, [actId])) as Array<{ + id: string; + visitId: string; + actYear: number; + actNumber: number; + code: string; + title: string; + status: InspectionActStatus; + currentVersion: number; + closureSha256: string | null; + occurredAt: Date; + visitCode: string; + visitTitle: string; + }>; + if (!act) throw actNotFound(); + if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_NOT_SEALED', + message: 'El INF sólo puede generarse después de sellar el acta', + }); + } + await this.assertActorAssigned(manager, act.visitId, principal.userId); + const [existing] = (await manager.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + )) as Array<{ id: string }>; + if (existing) return this.getWithManager(manager, existing.id); + + const [closure] = (await manager.query(` + SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" + FROM inspection_act_closures + WHERE act_id = $1 + `, [actId])) as Array<{ + finalSnapshot: Record | null; + finalSha256: string | null; + }>; + if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID', + message: 'El acta sellada no tiene una instantánea final válida', + }); + } + + const reportNumber = act.actNumber; + const code = act.code.replace(/^ACT-/, 'INF-'); + const generatedAt = new Date(); + const title = `Informe de inspección · ${act.code}`.slice(0, 220); + const frozenSnapshot = { + schemaVersion: REPORT_SCHEMA_VERSION, + source: { + actId: act.id, + actCode: act.code, + actVersion: act.currentVersion, + actClosureSha256: act.closureSha256, + inspectionId: act.visitId, + inspectionCode: act.visitCode, + }, + sealedAct: closure.finalSnapshot, + }; + const frozenSha256 = sha256CanonicalJson(frozenSnapshot); + const [created] = (await manager.query(` + INSERT INTO inspection_reports ( + visit_id,act_id,report_year,report_number,code,status,pdf_status, + executive_summary,report_description,title,act_version,act_closure_sha256, + frozen_sha256,frozen_snapshot,generated_at,generated_by + ) VALUES ( + $1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12 + ) + RETURNING id + `, [ + act.visitId, + act.id, + act.actYear, + reportNumber, + code, + title, + act.currentVersion, + act.closureSha256, + frozenSha256, + frozenSnapshot, + generatedAt, + principal.userId, + ])) as Array<{ id: string }>; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_GENERATED, + entityType: 'inspection_report', + entityId: created.id, + afterData: { code, - title, - act.currentVersion, - act.closureSha256, + actId: act.id, + inspectionId: act.visitId, + actVersion: act.currentVersion, frozenSha256, - frozenSnapshot, - generatedAt, - principal.userId, - ])) as Array<{ id: string }>; - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_GENERATED, - entityType: 'inspection_report', - entityId: created.id, - afterData: { - code, - actId: act.id, - visitId: act.visitId, - actVersion: act.currentVersion, - frozenSha256, - pdfStatus: InspectionReportPdfStatus.PENDING, - }, - }, manager); - return this.getWithManager(manager, created.id); + status: InspectionReportStatus.WORKING, + }, + }, manager); + return this.getWithManager(manager, created.id); } async ensureWordForAct(actId: string): Promise { - const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>; + const [row] = await this.dataSource.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + ) as Array<{ id: string }>; if (row) await this.word.ensure(row.id); await this.delivery.dispatchForAct(actId).catch(() => undefined); } @@ -398,25 +386,11 @@ export class InspectionReportsService { conditions.push(`( report.code ILIKE ${search} OR report.title ILIKE ${search} + OR report.gedo_if_identifier ILIKE ${search} OR act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code ILIKE ${search} OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`report.report_year = ${add(query.year)}`); @@ -454,20 +428,16 @@ export class InspectionReportsService { return ` SELECT COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name - )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, + 'id',company.id,'code',company.code,'name',company.name + )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name - )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas + 'id',area.id,'code',area.code,'name',area.name + )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id - LEFT JOIN assets company ON company.id = context_asset.operator_company_id - LEFT JOIN assets area ON area.id = context_asset.operational_area_id - WHERE context_link.act_id = act.id AND context_link.included = true + INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id + LEFT JOIN assets company ON company.id=context_asset.operator_company_id + LEFT JOIN assets area ON area.id=context_asset.operational_area_id + WHERE context_link.act_id=act.id AND context_link.included=true `; } @@ -481,50 +451,43 @@ export class InspectionReportsService { report.report_number AS "reportNumber", report.code, report.status, + report.executive_summary AS "executiveSummary", + report.report_description AS "reportDescription", + report.gedo_if_identifier AS "gedoIfIdentifier", + report.gedo_officialized_at AS "gedoOfficializedAt", + report.gedo_pdf_original_name AS "gedoPdfOriginalName", + report.gedo_pdf_sha256 AS "gedoPdfSha256", report.pdf_status AS "pdfStatus", report.word_status AS "wordStatus", report.word_generated_at AS "wordGeneratedAt", - report.review_status AS "reviewStatus", - report.current_revision_number AS "currentRevisionNumber", - report.approved_at AS "approvedAt", - report.signed_at AS "signedAt", report.title, report.act_version AS "actVersion", report.act_closure_sha256 AS "actClosureSha256", report.frozen_sha256 AS "frozenSha256", report.generated_at AS "generatedAt", JSONB_BUILD_OBJECT( - 'id', generator.id, - 'username', generator.username, - 'firstName', generator.first_name, - 'lastName', generator.last_name + 'id',generator.id,'username',generator.username, + 'firstName',generator.first_name,'lastName',generator.last_name ) AS "generatedBy", JSONB_BUILD_OBJECT( - 'id', act.id, - 'code', act.code, - 'title', act.title, - 'status', act.status, - 'occurredAt', act.occurred_at, - 'closedAt', act.closed_at + 'id',act.id,'code',act.code,'title',act.title,'status',act.status, + 'occurredAt',act.occurred_at,'sealedAt',act.sealed_at, + 'urgency',act.urgency,'deadlineAt',act.deadline_at ) AS act, JSONB_BUILD_OBJECT( - 'id', visit.id, - 'code', visit.code, - 'title', visit.title, - 'status', visit.status + 'id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status ) AS visit, - COALESCE(context.companies, '[]'::jsonb) AS companies, - COALESCE(context.areas, '[]'::jsonb) AS areas, - COALESCE(finding_count.total, 0)::integer AS "findingCount" + COALESCE(context.companies,'[]'::jsonb) AS companies, + COALESCE(context.areas,'[]'::jsonb) AS areas, + COALESCE(finding_count.total,0)::integer AS "findingCount" FROM inspection_reports report - INNER JOIN inspection_acts act ON act.id = report.act_id - INNER JOIN inspection_visits visit ON visit.id = report.visit_id - INNER JOIN users generator ON generator.id = report.generated_by + INNER JOIN inspection_acts act ON act.id=report.act_id + INNER JOIN inspection_visits visit ON visit.id=report.visit_id + INNER JOIN users generator ON generator.id=report.generated_by LEFT JOIN LATERAL (${this.contextSelect()}) context ON true LEFT JOIN LATERAL ( - SELECT COUNT(*) AS total - FROM inspection_findings finding - WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + SELECT COUNT(*) AS total FROM inspection_findings finding + WHERE finding.act_id=act.id AND finding.status<>'VOIDED' ) finding_count ON true ${where} `; @@ -543,29 +506,15 @@ export class InspectionReportsService { return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; } - private async allocateNumber(manager: EntityManager, year: number): Promise { - const [row] = (await manager.query(` - INSERT INTO document_annual_sequences (document_type, year, last_number) - VALUES ($1, $2, 1) - ON CONFLICT (document_type, year) DO UPDATE SET - last_number = document_annual_sequences.last_number + 1, - updated_at = CURRENT_TIMESTAMP - RETURNING last_number AS number - `, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>; - return Number(row.number); - } - private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise { const rows = (await manager.query(` - SELECT 1 - FROM inspection_visit_members - WHERE visit_id = $1 AND user_id = $2 AND included = true - LIMIT 1 + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 `, [visitId, userId])) as unknown[]; - if (rows.length === 0) { + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', - message: 'Sólo un inspector asignado a la visita puede solicitar el informe', + message: 'Sólo un inspector asignado a la inspección puede solicitar el informe', }); } } From b8fde4c32dd9d690230b2e4d668004866331f855 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:56:27 -0300 Subject: [PATCH 023/224] feat(f4): add editable INF narrative DTO --- .../dto/update-inspection-report.dto.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts diff --git a/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts new file mode 100644 index 0000000..dc73883 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-inspection-report.dto.ts @@ -0,0 +1,16 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateInspectionReportDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(20000) + executiveSummary?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(40000) + description?: string | null; +} From 0c35e801eb6c3795c25a2dd6060774537060f6a0 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:56:32 -0300 Subject: [PATCH 024/224] feat(f4): add GEDO officialization DTO --- .../dto/officialize-inspection-report.dto.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts diff --git a/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts b/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts new file mode 100644 index 0000000..d11e599 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/officialize-inspection-report.dto.ts @@ -0,0 +1,13 @@ +import { Transform } from 'class-transformer'; +import { IsISO8601, IsString, MaxLength, MinLength } from 'class-validator'; + +export class OfficializeInspectionReportDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(255) + gedoIfIdentifier!: string; + + @IsISO8601({ strict: true }) + gedoOfficializedAt!: string; +} From ad66bb3e6196f304ab1eb8800b15bbcb4d1dd8c6 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:56:39 -0300 Subject: [PATCH 025/224] feat(f4): add append-only INF follow-up DTO --- .../create-inspection-report-follow-up.dto.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts diff --git a/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts new file mode 100644 index 0000000..e777ba7 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts @@ -0,0 +1,23 @@ +import { Transform } from 'class-transformer'; +import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator'; +import { InspectionReportFollowUpType } from '../../database/entities'; + +export class CreateInspectionReportFollowUpDto { + @IsEnum(InspectionReportFollowUpType) + type!: InspectionReportFollowUpType; + + @IsISO8601({ strict: true }) + occurredAt!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(255) + externalReference?: string | null; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(20000) + description?: string | null; +} From 31256ea0b5f2a99885fb18c8057b0f40f897f3d9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:57:22 -0300 Subject: [PATCH 026/224] feat(f4): implement INF editing, GEDO officialization and follow-up --- .../inspection-report-workflow.service.ts | 433 ++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 api-v3/src/inspection-reports/inspection-report-workflow.service.ts diff --git a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts new file mode 100644 index 0000000..41d06d3 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -0,0 +1,433 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditAction, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, + InspectionReportStatus, +} from '../database/entities'; +import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; +import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; +import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; + +export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024; + +export interface UploadedInspectionReportFile { + buffer: Buffer; + originalname: string; + mimetype: string; + size: number; +} + +interface ReportRow { + id: string; + actId: string; + visitId: string; + code: string; + status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; +} + +function reportNotFound(): NotFoundException { + return new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); +} + +@Injectable() +export class InspectionReportWorkflowService { + private readonly root: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + config: ConfigService, + ) { + const configured = config.get('INSPECTION_REPORT_UPLOAD_ROOT') + ?? '/app/storage/inspection-reports'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) { + throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root'); + } + } + + async updateNarrative( + reportId: string, + dto: UpdateInspectionReportDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO', + }); + } + const before = { + executiveSummary: report.executiveSummary, + reportDescription: report.reportDescription, + }; + const executiveSummary = dto.executiveSummary !== undefined + ? dto.executiveSummary + : report.executiveSummary; + const reportDescription = dto.description !== undefined + ? dto.description + : report.reportDescription; + await manager.query(` + UPDATE inspection_reports + SET executive_summary=$2, + report_description=$3, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [reportId, executiveSummary, reportDescription]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_UPDATED, + entityType: 'inspection_report', + entityId: reportId, + beforeData: before, + afterData: { executiveSummary, reportDescription }, + metadata: { reportCode: report.code, actId: report.actId }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } + + async officialize( + reportId: string, + dto: OfficializeInspectionReportDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + this.assertPdf(file); + const id = randomUUID(); + const storedName = `gedo-${id}.pdf`; + const filePath = resolve(this.root, storedName); + const sha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + if (report.status !== InspectionReportStatus.WORKING) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED', + message: 'El IF oficial de GEDO ya fue registrado y es inmutable', + }); + } + const officializedAt = new Date(dto.gedoOfficializedAt); + const now = new Date(); + if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INVALID_GEDO_DATE', + message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro', + }); + } + await manager.query(` + UPDATE inspection_reports + SET status='OFFICIALIZED', + gedo_if_identifier=$2, + gedo_officialized_at=$3, + gedo_pdf_original_name=$4, + gedo_pdf_stored_name=$5, + gedo_pdf_mime_type='application/pdf', + gedo_pdf_size_bytes=$6, + gedo_pdf_sha256=$7, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [ + reportId, + dto.gedoIfIdentifier, + officializedAt, + file!.originalname, + storedName, + file!.buffer.length, + sha256, + ]); + await this.activateNonUrgentDeadline(manager, report.actId, officializedAt); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_OFFICIALIZED, + entityType: 'inspection_report', + entityId: reportId, + afterData: { + status: InspectionReportStatus.OFFICIALIZED, + gedoIfIdentifier: dto.gedoIfIdentifier, + gedoOfficializedAt: officializedAt, + gedoPdfOriginalName: file!.originalname, + gedoPdfSha256: sha256, + }, + metadata: { reportCode: report.code, actId: report.actId, immutable: true }, + }, manager); + return this.getWorkflowView(manager, reportId); + }); + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async addFollowUp( + reportId: string, + dto: CreateInspectionReportFollowUpDto, + file: UploadedInspectionReportFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'INSPECTION_REPORT_FILE_TOO_LARGE', + message: 'El archivo supera el máximo permitido de 40 MB', + }); + } + const followUpId = randomUUID(); + const ext = this.safeExtension(file?.originalname); + const storedName = file ? `followup-${followUpId}${ext}` : null; + const filePath = storedName ? resolve(this.root, storedName) : null; + const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null; + if (filePath && file) { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 }); + } + try { + return await this.dataSource.transaction(async (manager) => { + const report = await this.lockReport(manager, reportId); + const occurredAt = new Date(dto.occurredAt); + await manager.query(` + INSERT INTO inspection_report_follow_ups ( + id,report_id,type,external_reference,occurred_at,description, + original_name,stored_name,mime_type,size_bytes,sha256,created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + `, [ + followUpId, + reportId, + dto.type, + dto.externalReference ?? null, + occurredAt, + dto.description ?? null, + file?.originalname ?? null, + storedName, + file?.mimetype ?? null, + file?.buffer.length ?? null, + sha256, + principal.userId, + ]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED, + entityType: 'inspection_report_follow_up', + entityId: followUpId, + afterData: { + reportId, + type: dto.type, + externalReference: dto.externalReference ?? null, + occurredAt, + description: dto.description ?? null, + originalName: file?.originalname ?? null, + sha256, + }, + metadata: { reportCode: report.code, actId: report.actId, appendOnly: true }, + }, manager); + return this.listFollowUpsWithManager(manager, reportId); + }); + } catch (error) { + if (filePath) await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async listFollowUps(reportId: string) { + const report = await this.getReport(this.dataSource.manager, reportId); + if (!report) throw reportNotFound(); + return this.listFollowUpsWithManager(this.dataSource.manager, reportId); + } + + private async listFollowUpsWithManager(manager: EntityManager, reportId: string) { + return manager.query(` + SELECT + follow_up.id, + follow_up.report_id AS "reportId", + follow_up.type, + follow_up.external_reference AS "externalReference", + follow_up.occurred_at AS "occurredAt", + follow_up.description, + follow_up.original_name AS "originalName", + follow_up.mime_type AS "mimeType", + follow_up.size_bytes AS "sizeBytes", + follow_up.sha256, + follow_up.created_by AS "createdBy", + follow_up.created_at AS "createdAt" + FROM inspection_report_follow_ups follow_up + WHERE follow_up.report_id=$1 + ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id + `, [reportId]); + } + + private async activateNonUrgentDeadline( + manager: EntityManager, + actId: string, + officializedAt: Date, + ): Promise { + const [act] = await manager.query(` + SELECT urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", + deadline_basis AS "deadlineBasis",deadline_at AS "deadlineAt" + FROM inspection_acts WHERE id=$1 FOR UPDATE + `, [actId]) as Array<{ + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineAt: Date | null; + }>; + if (!act) return; + if (act.urgency !== InspectionActUrgency.NON_URGENT) return; + if (act.deadlineBasis !== InspectionDeadlineBasis.GEDO_DATE) { + throw new ConflictException({ + code: 'INVALID_NON_URGENT_DEADLINE_BASIS', + message: 'El acta no urgente no conserva la regla GEDO necesaria para calcular su vencimiento', + }); + } + if (!act.deadlineDays || !act.deadlineDayType) { + throw new ConflictException({ + code: 'MISSING_ACT_DEADLINE_SNAPSHOT', + message: 'El acta no conserva la política de plazo aplicada al momento de finalizarse', + }); + } + const deadlineAt = await this.calculateDeadline( + manager, + officializedAt, + Number(act.deadlineDays), + act.deadlineDayType, + ); + await manager.query(` + UPDATE inspection_acts + SET deadline_base_at=$2,deadline_at=$3,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [actId, officializedAt, deadlineAt]); + } + + private async calculateDeadline( + manager: EntityManager, + baseAt: Date, + days: number, + dayType: InspectionDeadlineDayType, + ): Promise { + if (dayType === InspectionDeadlineDayType.CALENDAR) { + const [row] = await manager.query(` + SELECT ( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + + time '23:59:59' + ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at + `, [baseAt, days]) as Array<{ due_at: Date }>; + return row.due_at; + } + const [row] = await manager.query(` + WITH candidates AS ( + SELECT day::date AS day, + COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business + FROM generate_series( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, + interval '1 day' + ) day + LEFT JOIN inspection_business_calendar_days override ON override.date=day::date + ), ranked AS ( + SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position FROM candidates WHERE business=true + ) + SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at + FROM ranked WHERE position=$2 LIMIT 1 + `, [baseAt, days]) as Array<{ due_at: Date }>; + if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); + return row.due_at; + } + + private assertPdf(file: UploadedInspectionReportFile | undefined): void { + if (!file?.buffer?.length) { + throw new BadRequestException({ + code: 'GEDO_PDF_REQUIRED', + message: 'Debe adjuntarse el PDF oficial generado por GEDO', + }); + } + if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) { + throw new BadRequestException({ + code: 'GEDO_PDF_TOO_LARGE', + message: 'El PDF oficial supera el máximo permitido de 40 MB', + }); + } + if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') { + throw new BadRequestException({ + code: 'INVALID_GEDO_PDF', + message: 'El documento oficial de GEDO debe ser un PDF válido', + }); + } + } + + private safeExtension(name: string | undefined): string { + if (!name) return ''; + const dot = name.lastIndexOf('.'); + if (dot < 0) return ''; + const ext = name.slice(dot).toLowerCase(); + return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : ''; + } + + private async lockReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 FOR UPDATE + `, [id]) as ReportRow[]; + if (!row) throw reportNotFound(); + return row; + } + + private async getReport(manager: EntityManager, id: string): Promise { + const [row] = await manager.query(` + SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, + executive_summary AS "executiveSummary",report_description AS "reportDescription", + gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt" + FROM inspection_reports WHERE id=$1 + `, [id]) as ReportRow[]; + return row ?? null; + } + + private async getWorkflowView(manager: EntityManager, id: string) { + const report = await this.getReport(manager, id); + if (!report) throw reportNotFound(); + const [act] = await manager.query(` + SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType", + deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt" + FROM inspection_acts WHERE id=$1 + `, [report.actId]); + return { + ...report, + act, + followUps: await this.listFollowUpsWithManager(manager, id), + }; + } +} From 39846c81a483d2b62bdcad2f887bff8be846a06c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:57:37 -0300 Subject: [PATCH 027/224] feat(f4): expose INF editing, GEDO and follow-up endpoints --- .../inspection-reports.controller.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index 536a543..3f44101 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -1,15 +1,41 @@ -import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { Response } from 'express'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto'; import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; +import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { + InspectionReportWorkflowService, + MAX_INSPECTION_REPORT_FILE_BYTES, + type UploadedInspectionReportFile, +} from './inspection-report-workflow.service'; import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportWordService } from './inspection-report-word.service'; @Controller('inspection-reports') export class InspectionReportsController { - constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {} + constructor( + private readonly reports: InspectionReportsService, + private readonly word: InspectionReportWordService, + private readonly workflow: InspectionReportWorkflowService, + ) {} @Get() @RequirePermissions('inspection_reports.read') @@ -42,6 +68,53 @@ export class InspectionReportsController { return response.sendFile(content.filePath); } + @Patch(':id') + @RequirePermissions('inspection_reports.generate') + updateNarrative( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInspectionReportDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.updateNarrative(id, dto, principal, request); + } + + @Post(':id/officialize') + @RequirePermissions('inspection_reports.generate') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 }, + })) + officialize( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: OfficializeInspectionReportDto, + @UploadedFile() file: UploadedInspectionReportFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.officialize(id, dto, file, principal, request); + } + + @Get(':id/follow-ups') + @RequirePermissions('inspection_reports.read') + followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.workflow.listFollowUps(id); + } + + @Post(':id/follow-ups') + @RequirePermissions('inspection_reports.generate') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 }, + })) + addFollowUp( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CreateInspectionReportFollowUpDto, + @UploadedFile() file: UploadedInspectionReportFile | undefined, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.workflow.addFollowUp(id, dto, file, principal, request); + } + @Get(':id') @RequirePermissions('inspection_reports.read') get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { From a1d994b6d469e3f18a70535999412a98578607a3 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:57:47 -0300 Subject: [PATCH 028/224] feat(f4): register INF workflow service --- api-v3/src/inspection-reports/inspection-reports.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index 3ef820c..ff43f14 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -3,6 +3,7 @@ import { AuditModule } from '../audit/audit.module'; import { DocumentDeliveryController } from './document-delivery.controller'; import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; +import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; @@ -17,6 +18,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; ], providers: [ InspectionReportsService, + InspectionReportWorkflowService, InspectionReportWordService, InspectionActPdfService, InspectionDocumentDeliveryService, From 96093d9ed40b8eab5c97bc5d70573fca3a2bb39a Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:58:49 -0300 Subject: [PATCH 029/224] refactor(f4): deliver editable INF Word to inspector --- .../inspection-document-delivery.service.ts | 253 ++++++------------ 1 file changed, 79 insertions(+), 174 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts index d855648..7b92038 100644 --- a/api-v3/src/inspection-reports/inspection-document-delivery.service.ts +++ b/api-v3/src/inspection-reports/inspection-document-delivery.service.ts @@ -10,7 +10,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service'; import { InspectionReportWordService } from './inspection-report-word.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; -export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR'; +export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR'; export interface DeliveryRow { id: string; @@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService { async settings() { const [row] = await this.dataSource.query(` - SELECT - office_email AS "officeEmail", - director_email AS "directorEmail", - updated_at AS "updatedAt" + SELECT office_email AS "officeEmail",updated_at AS "updatedAt" FROM institutional_delivery_settings WHERE id=1 - `) as Array<{ - officeEmail: string | null; - directorEmail: string | null; - updatedAt: Date; - }>; + `) as Array<{ officeEmail: string | null; updatedAt: Date }>; return { ...row, - smtpConfigured: this.smtp.configured(), - mailFrom: this.config.get('MAIL_FROM') ?? null, + smtpConfigured: await this.smtp.configured(), + mailFrom: await this.smtp.fromAddress() ?? this.config.get('MAIL_FROM') ?? null, }; } @@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService { const before = await this.settings(); await this.dataSource.query(` UPDATE institutional_delivery_settings - SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP + SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=1 - `, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]); + `, [dto.officeEmail ?? null, principal.userId]); const after = await this.settings(); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService { async list() { const data = await this.dataSource.query(` SELECT - d.id, - d.act_id AS "actId", - d.report_id AS "reportId", - d.document_kind AS "documentKind", - d.recipient_kind AS "recipientKind", - d.recipient_asset_id AS "recipientAssetId", - d.recipient_user_id AS "recipientUserId", - d.recipient_email AS "recipientEmail", - d.status, - d.attempts, - d.last_attempt_at AS "lastAttemptAt", - d.sent_at AS "sentAt", - d.provider_message_id AS "providerMessageId", - d.last_error AS "lastError", - d.created_at AS "createdAt", - a.code AS "actCode", - r.code AS "reportCode", + d.id,d.act_id AS "actId",d.report_id AS "reportId", + d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", + d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", + d.recipient_email AS "recipientEmail",d.status,d.attempts, + d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt", + d.provider_message_id AS "providerMessageId",d.last_error AS "lastError", + d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode", recipient.name AS "recipientAssetName", - CASE - WHEN recipient_user.id IS NULL THEN NULL - ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name)) + CASE WHEN recipient_user.id IS NULL THEN NULL + ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name)) END AS "recipientUserName" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id + WHERE d.recipient_kind<>'DIRECTOR' ORDER BY d.created_at DESC LIMIT 200 `); @@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService { async retryPending(principal: AuthPrincipal, request: RequestWithContext) { const rows = await this.dataSource.query(` - SELECT id - FROM inspection_document_deliveries - WHERE status<>'SENT' - ORDER BY created_at ASC - LIMIT 100 + SELECT id FROM inspection_document_deliveries + WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR' + ORDER BY created_at ASC LIMIT 100 `) as Array<{ id: string }>; for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined); return { processed: rows.length }; @@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService { private async ensureRows(actId: string, reportId: string | null) { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" - FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; + SELECT office_email AS "officeEmail" + FROM institutional_delivery_settings WHERE id=1 + `) as Array<{ officeEmail: string | null }>; const companies = await this.dataSource.query(` SELECT DISTINCT company.id,profile.notification_email AS email @@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService { `, [actId]) as Array<{ id: string; email: string | null }>; const [inspector] = await this.dataSource.query(` - SELECT - inspector.id, - inspector.email + SELECT inspector.id,inspector.email FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id JOIN users inspector ON inspector.id=visit.lead_inspector_user_id @@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService { for (const company of companies) { await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'COMPANY', - recipientAssetId: company.id, - recipientUserId: null, - recipientKey: company.id, - recipientEmail: company.email, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY', + recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id, + recipientEmail:company.email, }); } await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'OFFICE', - recipientAssetId: null, - recipientUserId: null, - recipientKey: '00000000-0000-0000-0000-000000000000', - recipientEmail: settings?.officeEmail ?? null, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE', + recipientAssetId:null,recipientUserId:null, + recipientKey:'00000000-0000-0000-0000-000000000000', + recipientEmail:settings?.officeEmail ?? null, }); if (inspector) { await this.upsertRow({ - actId, - reportId, - documentKind: 'ACT_PDF', - recipientKind: 'INSPECTOR', - recipientAssetId: null, - recipientUserId: inspector.id, - recipientKey: inspector.id, - recipientEmail: inspector.email, - }); - } - - if (reportId) { - await this.upsertRow({ - actId, - reportId, - documentKind: 'REPORT_WORD', - recipientKind: 'DIRECTOR', - recipientAssetId: null, - recipientUserId: null, - recipientKey: '00000000-0000-0000-0000-000000000000', - recipientEmail: settings?.directorEmail ?? null, + actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR', + recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id, + recipientEmail:inspector.email, }); + if (reportId) { + await this.upsertRow({ + actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR', + recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id, + recipientEmail:inspector.email, + }); + } } } @@ -267,31 +225,21 @@ export class InspectionDocumentDeliveryService { recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id), recipient_email=CASE WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email - ELSE EXCLUDED.recipient_email - END, + ELSE EXCLUDED.recipient_email END, status=CASE WHEN inspection_document_deliveries.status='SENT' THEN 'SENT' WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT' - ELSE inspection_document_deliveries.status - END, + ELSE inspection_document_deliveries.status END, updated_at=CURRENT_TIMESTAMP `, [ - input.actId, - input.reportId, - input.documentKind, - input.recipientKind, - input.recipientAssetId, - input.recipientUserId, - input.recipientKey, - input.recipientEmail, - initialStatus, + input.actId,input.reportId,input.documentKind,input.recipientKind, + input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus, ]); } private async rowsForAct(actId: string): Promise { return this.dataSource.query(` - SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", + SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_email AS "recipientEmail",d.status,d.attempts, @@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService { FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.act_id=$1 + WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR' ORDER BY d.created_at `, [actId]) as Promise; } private async load(id: string): Promise { const [row] = await this.dataSource.query(` - SELECT - d.id,d.act_id AS "actId",d.report_id AS "reportId", + SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId", d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind", d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId", d.recipient_email AS "recipientEmail",d.status,d.attempts, @@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService { FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id - WHERE d.id=$1 + WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR' `, [id]) as DeliveryRow[]; if (!row) { throw new NotFoundException({ - code: 'DOCUMENT_DELIVERY_NOT_FOUND', - message: 'Entrega documental no encontrada', + code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada', }); } return row; @@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService { let email: string | null = null; if (row.recipientKind === 'COMPANY' && row.recipientAssetId) { const [company] = await this.dataSource.query(` - SELECT notification_email AS email - FROM organization_profiles - WHERE asset_id=$1 + SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1 `, [row.recipientAssetId]) as Array<{ email: string | null }>; email = company?.email ?? null; } else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) { const [inspector] = await this.dataSource.query(` - SELECT email - FROM users - WHERE id=$1 AND is_active=true + SELECT email FROM users WHERE id=$1 AND is_active=true `, [row.recipientUserId]) as Array<{ email: string | null }>; email = inspector?.email ?? null; } else { const [settings] = await this.dataSource.query(` - SELECT office_email AS "officeEmail",director_email AS "directorEmail" - FROM institutional_delivery_settings - WHERE id=1 - `) as Array<{ officeEmail: string | null; directorEmail: string | null }>; - email = row.recipientKind === 'OFFICE' - ? settings?.officeEmail ?? null - : settings?.directorEmail ?? null; + SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1 + `) as Array<{ officeEmail: string | null }>; + email = settings?.officeEmail ?? null; } - await this.dataSource.query(` UPDATE inspection_document_deliveries SET recipient_email=$2, status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END, - last_error=NULL, - updated_at=CURRENT_TIMESTAMP + last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status<>'SENT' - `, [row.id, email]); + `, [row.id,email]); } private async attempt(row: DeliveryRow) { if (row.status === 'SENT') return; if (!row.recipientEmail) { - await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado'); + await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado'); return; } - if (!this.smtp.configured()) { - await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado'); + if (!await this.smtp.configured()) { + await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado'); return; } @@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService { if (row.documentKind === 'ACT_PDF') { await this.pdf.ensure(row.actId); const file = await this.pdf.content(row.actId); - attachment = { - filename: file.originalName, - mimeType: file.mimeType, - content: file.buffer, - }; + attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer }; } else { if (!row.reportId) throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file = await this.word.content(row.reportId); const { readFile } = await import('node:fs/promises'); attachment = { - filename: file.originalName, - mimeType: file.mimeType, - content: await readFile(file.filePath), + filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath), }; } } catch (error) { - await this.setStatus( - row.id, - 'WAITING_ARTIFACT', - error instanceof Error ? error.message : 'Documento no disponible', - ); + await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible'); return; } @@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService { SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING', last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 - `, [row.id]); + `,[row.id]); try { const label = row.documentKind === 'ACT_PDF' ? `Acta ${row.actCode}` : `Informe ${row.reportCode ?? ''}`; const text = row.documentKind === 'REPORT_WORD' - ? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.` + ? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.` : row.recipientKind === 'INSPECTOR' - ? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.` - : `Se adjunta el acta cerrada e inmutable ${row.actCode}.`; + ? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.` + : `Se adjunta el acta sellada e inmutable ${row.actCode}.`; const sent = await this.smtp.send({ - to: row.recipientEmail, - subject: `DH Inspección · ${label}`, - text, - attachment, + to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment, }); await this.dataSource.query(` UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2, - last_error=NULL,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 - `, [row.id, sent.messageId]); + last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[row.id,sent.messageId]); await this.audit.record({ - action: AuditAction.DOCUMENT_DELIVERY_SENT, - entityType: 'inspection_document_delivery', - entityId: row.id, - source: AuditSource.SYSTEM, - actorUserId: null, - actorUsername: null, - requestId: null, - ip: null, - userAgent: null, - beforeData: null, - afterData: { - recipientKind: row.recipientKind, - recipientUserId: row.recipientUserId, - documentKind: row.documentKind, - status: 'SENT', - }, - metadata: { actId: row.actId, reportId: row.reportId }, + action:AuditAction.DOCUMENT_DELIVERY_SENT, + entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM, + actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null, + afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'}, + metadata:{actId:row.actId,reportId:row.reportId}, }); } catch (error) { - await this.setStatus( - row.id, - 'FAILED', - error instanceof Error ? error.message : 'Error de entrega', - ); + await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega'); } } - private async setStatus(id: string, status: string, error: string) { + private async setStatus(id: string,status: string,error: string) { await this.dataSource.query(` UPDATE inspection_document_deliveries - SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP - WHERE id=$1 - `, [id, status, error.slice(0, 500)]); + SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[id,status,error.slice(0,500)]); } } From ce3d3cf8b7490792e978f02a6b2d5bcd803e3b63 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:59:33 -0300 Subject: [PATCH 030/224] feat(f4): load SMTP from encrypted superadmin settings --- .../smtp-delivery.service.ts | 178 +++++++++++++++++- 1 file changed, 169 insertions(+), 9 deletions(-) diff --git a/api-v3/src/inspection-reports/smtp-delivery.service.ts b/api-v3/src/inspection-reports/smtp-delivery.service.ts index bce88d6..0e54de4 100644 --- a/api-v3/src/inspection-reports/smtp-delivery.service.ts +++ b/api-v3/src/inspection-reports/smtp-delivery.service.ts @@ -1,13 +1,27 @@ +import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto'; import { connect as connectNet, Socket } from 'node:net'; import { connect as connectTls, TLSSocket } from 'node:tls'; -import { randomUUID } from 'node:crypto'; -import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { SmtpSecurityMode } from '../database/entities'; interface MailAttachment { filename:string; mimeType:string; content:Buffer; } interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; } interface Reply { code:number; text:string; } +export interface EffectiveSmtpSettings { + source: 'DATABASE' | 'ENVIRONMENT'; + host: string; + port: number; + securityMode: SmtpSecurityMode; + username: string | null; + password: string; + fromName: string | null; + fromEmail: string; + replyTo: string | null; +} + class SmtpSession { private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[]; private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8')); @@ -20,18 +34,164 @@ class SmtpSession { async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; } write(data:string){this.socket.write(data);} end(){this.socket.end();} - current(){return this.socket;} } function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;} function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();} -function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); } +function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); } @Injectable() export class SmtpDeliveryService { - constructor(private readonly config:ConfigService){} - configured(){return Boolean(this.config.get('SMTP_HOST')&&this.config.get('MAIL_FROM'));} - async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get('SMTP_HOST'); const from=this.config.get('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get('SMTP_PORT')??587); const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get('SMTP_USER')??''; const pass=this.config.get('SMTP_PASS')??''; const base=await new Promise((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); } - if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } } - await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; } + constructor( + private readonly dataSource:DataSource, + private readonly config:ConfigService, + ){} + + async configured():Promise{return Boolean(await this.resolveSettings());} + + async fromAddress():Promise{ + const settings=await this.resolveSettings(); + if(!settings)return null; + return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; + } + + async publicSettings(){ + const [row]=await this.dataSource.query(` + SELECT id,host,port,security_mode AS "securityMode",username, + (password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName", + from_email AS "fromEmail",reply_to AS "replyTo",enabled, + updated_at AS "updatedAt" + FROM system_smtp_settings ORDER BY created_at LIMIT 1 + `) as Array>; + if(row)return {...row,source:'DATABASE'}; + const fallback=await this.resolveEnvironment(); + return fallback?{ + source:'ENVIRONMENT',host:fallback.host,port:fallback.port, + securityMode:fallback.securityMode,username:fallback.username, + hasPassword:Boolean(fallback.password),fromName:fallback.fromName, + fromEmail:fallback.fromEmail,replyTo:fallback.replyTo,enabled:true, + }:{source:'NONE',enabled:false}; + } + + async saveSettings(input:{ + host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null; + password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean; + },userId:string){ + const [existing]=await this.dataSource.query(`SELECT id,password_enc AS "passwordEnc" FROM system_smtp_settings ORDER BY created_at LIMIT 1`) as Array<{id:string;passwordEnc:string|null}>; + const passwordEnc=input.password===undefined + ? existing?.passwordEnc??null + : input.password?this.encryptSecret(input.password):null; + if(existing){ + await this.dataSource.query(` + UPDATE system_smtp_settings SET host=$2,port=$3,security_mode=$4,username=$5, + password_enc=$6,from_name=$7,from_email=$8,reply_to=$9,enabled=$10, + updated_by=$11,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `,[existing.id,input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]); + }else{ + await this.dataSource.query(` + INSERT INTO system_smtp_settings(host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + `,[input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]); + } + return this.publicSettings(); + } + + async send(input:MailInput):Promise<{messageId:string}>{ + const settings=await this.resolveSettings(); + if(!settings)throw new Error('SMTP no configurado'); + const {host,port,securityMode,userName,password}= { + host:settings.host,port:settings.port,securityMode:settings.securityMode, + userName:settings.username??'',password:settings.password, + }; + const implicitTls=securityMode===SmtpSecurityMode.TLS; + const base=await new Promise((resolve,reject)=>{ + if(implicitTls){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } + else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } + }); + const session=new SmtpSession(base); + const welcome=await session.reply(); + if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); + let ehlo=await session.command('EHLO dh-inspeccion',250); + if(securityMode===SmtpSecurityMode.STARTTLS){ + if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); + await session.command('STARTTLS',220); + const rawForTls=session.detachForUpgrade() as Socket; + const upgraded=await new Promise((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); + session.replaceSocket(upgraded); + ehlo=await session.command('EHLO dh-inspeccion',250); + } + if(userName){ + if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${userName}\u0000${password}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } + else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(userName).toString('base64'),334); await session.command(Buffer.from(password).toString('base64'),235); } + } + const from=settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail; + await session.command(`MAIL FROM:<${envelope(from)}>`,250); + await session.command(`RCPT TO:<${input.to}>`,[250,251]); + await session.command('DATA',354); + session.write(`${mime(input,from,settings.replyTo)}\r\n.\r\n`); + const sent=await session.reply(); + if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); + await session.command('QUIT',221).catch(()=>undefined); + session.end(); + const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); + return {messageId:match?.[1]??randomUUID()}; + } + + private async resolveSettings():Promise{ + const [row]=await this.dataSource.query(` + SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc", + from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled + FROM system_smtp_settings ORDER BY created_at LIMIT 1 + `) as Array<{ + host:string;port:number;securityMode:SmtpSecurityMode;username:string|null;passwordEnc:string|null; + fromName:string;fromEmail:string;replyTo:string|null;enabled:boolean; + }>; + if(row){ + if(!row.enabled)return null; + return { + source:'DATABASE',host:row.host,port:Number(row.port),securityMode:row.securityMode, + username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'', + fromName:row.fromName,fromEmail:row.fromEmail,replyTo:row.replyTo, + }; + } + return this.resolveEnvironment(); + } + + private async resolveEnvironment():Promise{ + const host=this.config.get('SMTP_HOST'); + const rawFrom=this.config.get('MAIL_FROM'); + if(!host||!rawFrom)return null; + const fromEmail=envelope(rawFrom); + const fromName=rawFrom.includes('<')?rawFrom.slice(0,rawFrom.indexOf('<')).trim().replace(/^"|"$/g,'')||null:null; + const secure=String(this.config.get('SMTP_SECURE')??'false').toLowerCase()==='true'; + return { + source:'ENVIRONMENT',host,port:Number(this.config.get('SMTP_PORT')??(secure?465:587)), + securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS, + username:this.config.get('SMTP_USER')??null,password:this.config.get('SMTP_PASS')??'', + fromName,fromEmail,replyTo:null, + }; + } + + private encryptionKey():Buffer{ + const raw=this.config.get('SMTP_SETTINGS_MASTER_KEY'); + if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada'); + 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'); + return key; + } + + private encryptSecret(value:string):string{ + const iv=randomBytes(12); const cipher=createCipheriv('aes-256-gcm',this.encryptionKey(),iv); + const encrypted=Buffer.concat([cipher.update(value,'utf8'),cipher.final()]); + const tag=cipher.getAuthTag(); + return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`; + } + + private decryptSecret(value:string):string{ + const [version,ivB64,tagB64,cipherB64]=value.split(':'); + if(version!=='v1'||!ivB64||!tagB64||!cipherB64)throw new Error('Credencial SMTP cifrada inválida'); + const decipher=createDecipheriv('aes-256-gcm',this.encryptionKey(),Buffer.from(ivB64,'base64')); + decipher.setAuthTag(Buffer.from(tagB64,'base64')); + return Buffer.concat([decipher.update(Buffer.from(cipherB64,'base64')),decipher.final()]).toString('utf8'); + } } From a7f53f32f01e25b6c89672061df1a2f3315f6006 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:01:02 -0300 Subject: [PATCH 031/224] refactor(f4): remove obsolete Director delivery setting --- .../dto/update-document-delivery-settings.dto.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts index 3ed84a0..b5be54d 100644 --- a/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts +++ b/api-v3/src/inspection-reports/dto/update-document-delivery-settings.dto.ts @@ -1,2 +1,12 @@ -import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator'; -export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; } +import { Transform } from 'class-transformer'; +import { IsEmail, IsOptional, MaxLength } from 'class-validator'; + +export class UpdateDocumentDeliverySettingsDto { + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, + ) + @IsEmail() + @MaxLength(320) + officeEmail?: string | null; +} From 489fb309b3cb6dadc9ceaf435ad0c74ade3b18b9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:01:14 -0300 Subject: [PATCH 032/224] feat(f4): add superadmin SMTP settings DTO --- .../dto/update-smtp-settings.dto.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts diff --git a/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts new file mode 100644 index 0000000..8f70893 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-smtp-settings.dto.ts @@ -0,0 +1,71 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { SmtpSecurityMode } from '../../database/entities'; + +export class UpdateSmtpSettingsDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(255) + host!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(65535) + port!: number; + + @IsEnum(SmtpSecurityMode) + securityMode!: SmtpSecurityMode; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value) + @IsString() + @MaxLength(255) + username?: string | null; + + /** Omitir para conservar la clave actual; enviar null/cadena vacía para quitarla. */ + @IsOptional() + @IsString() + @MaxLength(1000) + password?: string | null; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + fromName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value) + @IsEmail() + @MaxLength(320) + fromEmail!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null) + @IsEmail() + @MaxLength(320) + replyTo?: string | null; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + enabled!: boolean; +} + +export class TestSmtpSettingsDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value) + @IsEmail() + @MaxLength(320) + email!: string; +} From 2d97398c018af9c153fbe24672e2d03ff6365094 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:01:44 -0300 Subject: [PATCH 033/224] feat(f4): add Superadmin SMTP management endpoints --- .../document-delivery.controller.ts | 113 ++++++++++++++++-- 1 file changed, 106 insertions(+), 7 deletions(-) diff --git a/api-v3/src/inspection-reports/document-delivery.controller.ts b/api-v3/src/inspection-reports/document-delivery.controller.ts index 54704b4..5fe49ca 100644 --- a/api-v3/src/inspection-reports/document-delivery.controller.ts +++ b/api-v3/src/inspection-reports/document-delivery.controller.ts @@ -1,17 +1,116 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common'; 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 { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import { AuditAction } from '../database/entities'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import type { DeliveryRow } from './inspection-document-delivery.service'; +import { SmtpDeliveryService } from './smtp-delivery.service'; import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto'; +import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto'; @Controller('document-delivery') export class DocumentDeliveryController { - constructor(private readonly delivery:InspectionDocumentDeliveryService){} - @Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();} - @Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);} - @Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();} - @Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise{return this.delivery.retry(id,principal,request);} - @Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);} + constructor( + private readonly delivery: InspectionDocumentDeliveryService, + private readonly smtp: SmtpDeliveryService, + private readonly audit: AuditService, + ) {} + + @Get('settings') + @RequirePermissions('document_delivery.read') + settings() { + return this.delivery.settings(); + } + + @Patch('settings') + @RequirePermissions('document_delivery.manage') + update( + @Body() dto: UpdateDocumentDeliverySettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.delivery.updateSettings(dto, principal, request); + } + + @Get('smtp') + @RequirePermissions('document_delivery.manage') + smtpSettings() { + return this.smtp.publicSettings(); + } + + @Put('smtp') + @RequirePermissions('document_delivery.manage') + async updateSmtp( + @Body() dto: UpdateSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + const before = await this.smtp.publicSettings(); + const after = await this.smtp.saveSettings(dto, principal.userId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.SMTP_SETTINGS_UPDATED, + entityType: 'system_smtp_settings', + entityId: 'singleton', + beforeData: before as Record, + afterData: after as Record, + metadata: { passwordNeverReturned: true }, + }); + return after; + } + + @Post('smtp/test') + @RequirePermissions('document_delivery.manage') + async testSmtp( + @Body() dto: TestSmtpSettingsDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + const sent = await this.smtp.send({ + to: dto.email, + subject: 'DH Inspección · Prueba SMTP', + text: 'Este correo confirma que la configuración SMTP de DH Inspección funciona correctamente.', + attachment: { + filename: 'dh-inspeccion-smtp-test.txt', + mimeType: 'text/plain', + content: Buffer.from('DH Inspección · Prueba SMTP OK\n', 'utf8'), + }, + }); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.SMTP_TEST_SENT, + entityType: 'system_smtp_settings', + entityId: 'singleton', + afterData: { recipient: dto.email, messageId: sent.messageId }, + }); + return { ok: true, recipient: dto.email, messageId: sent.messageId }; + } + + @Get('outbox') + @RequirePermissions('document_delivery.read') + outbox() { + return this.delivery.list(); + } + + @Post('outbox/:id/retry') + @RequirePermissions('document_delivery.manage') + retry( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ): Promise { + return this.delivery.retry(id, principal, request); + } + + @Post('outbox/retry-pending') + @RequirePermissions('document_delivery.manage') + retryPending( + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.delivery.retryPending(principal, request); + } } From d071e6be742e158f873a9906d5d050ca75cf2273 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:02:27 -0300 Subject: [PATCH 034/224] feat(f4): add JEFE deadline administration DTOs --- .../dto/update-deadline-policy.dto.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts diff --git a/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts b/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts new file mode 100644 index 0000000..4407473 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/update-deadline-policy.dto.ts @@ -0,0 +1,48 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { InspectionDeadlineDayType } from '../../database/entities'; + +export class UpdateDeadlinePolicyDto { + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + urgentDays!: number; + + @IsEnum(InspectionDeadlineDayType) + urgentDayType!: InspectionDeadlineDayType; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(365) + nonUrgentDays!: number; + + @IsEnum(InspectionDeadlineDayType) + nonUrgentDayType!: InspectionDeadlineDayType; +} + +export class UpsertBusinessCalendarDayDto { + @IsDateString() + date!: string; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + isBusinessDay!: boolean; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(200) + label!: string; +} From 9a79b7eb866806b7e9a982d084e584605721336e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:02:44 -0300 Subject: [PATCH 035/224] feat(f4): implement JEFE deadline policy and calendar administration --- .../inspection-deadline-admin.service.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 api-v3/src/inspection-reports/inspection-deadline-admin.service.ts diff --git a/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts b/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts new file mode 100644 index 0000000..d4e62aa --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-deadline-admin.service.ts @@ -0,0 +1,160 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import type { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto'; + +@Injectable() +export class InspectionDeadlineAdminService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async policy() { + const [row] = await this.dataSource.query(` + SELECT id, + urgent_days AS "urgentDays", + urgent_day_type AS "urgentDayType", + non_urgent_days AS "nonUrgentDays", + non_urgent_day_type AS "nonUrgentDayType", + updated_by AS "updatedBy", + updated_at AS "updatedAt" + FROM inspection_deadline_policies + ORDER BY created_at + LIMIT 1 + `); + return row ?? { + urgentDays: 5, + urgentDayType: 'BUSINESS', + nonUrgentDays: 10, + nonUrgentDayType: 'BUSINESS', + }; + } + + async updatePolicy( + dto: UpdateDeadlinePolicyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const before = await this.policy(); + const [existing] = await this.dataSource.query(` + SELECT id FROM inspection_deadline_policies ORDER BY created_at LIMIT 1 + `) as Array<{ id: string }>; + if (existing) { + await this.dataSource.query(` + UPDATE inspection_deadline_policies + SET urgent_days=$2, + urgent_day_type=$3, + non_urgent_days=$4, + non_urgent_day_type=$5, + updated_by=$6, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [ + existing.id, + dto.urgentDays, + dto.urgentDayType, + dto.nonUrgentDays, + dto.nonUrgentDayType, + principal.userId, + ]); + } else { + await this.dataSource.query(` + INSERT INTO inspection_deadline_policies( + urgent_days,urgent_day_type,non_urgent_days,non_urgent_day_type,updated_by + ) VALUES($1,$2,$3,$4,$5) + `, [ + dto.urgentDays, + dto.urgentDayType, + dto.nonUrgentDays, + dto.nonUrgentDayType, + principal.userId, + ]); + } + const after = await this.policy(); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_DEADLINE_POLICY_UPDATED, + entityType: 'inspection_deadline_policy', + entityId: String((after as { id?: string }).id ?? 'singleton'), + beforeData: before as Record, + afterData: after as Record, + metadata: { + appliesOnlyToFutureLockedActs: true, + historicalActsKeepSnapshot: true, + }, + }); + return after; + } + + async calendar() { + const data = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label, + updated_by AS "updatedBy",created_at AS "createdAt",updated_at AS "updatedAt" + FROM inspection_business_calendar_days + ORDER BY date + `); + return { data }; + } + + async upsertCalendarDay( + dto: UpsertBusinessCalendarDayDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const [before] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label + FROM inspection_business_calendar_days WHERE date=$1::date + `, [dto.date]); + await this.dataSource.query(` + INSERT INTO inspection_business_calendar_days(date,is_business_day,label,updated_by) + VALUES($1::date,$2,$3,$4) + ON CONFLICT(date) DO UPDATE SET + is_business_day=EXCLUDED.is_business_day, + label=EXCLUDED.label, + updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP + `, [dto.date, dto.isBusinessDay, dto.label, principal.userId]); + const [after] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label, + updated_by AS "updatedBy",updated_at AS "updatedAt" + FROM inspection_business_calendar_days WHERE date=$1::date + `, [dto.date]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED, + entityType: 'inspection_business_calendar_day', + entityId: String(after.id), + beforeData: before ?? null, + afterData: after, + }); + return after; + } + + async removeCalendarDay( + date: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const [before] = await this.dataSource.query(` + SELECT id,date,is_business_day AS "isBusinessDay",label + FROM inspection_business_calendar_days WHERE date=$1::date + `, [date]); + if (before) { + await this.dataSource.query(`DELETE FROM inspection_business_calendar_days WHERE id=$1`, [before.id]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED, + entityType: 'inspection_business_calendar_day', + entityId: String(before.id), + beforeData: before, + afterData: null, + metadata: { removed: true }, + }); + } + return { removed: Boolean(before), date }; + } +} From 5e6631554a34b86acfbce3a1feb40887c861807e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:02:54 -0300 Subject: [PATCH 036/224] feat(f4): expose JEFE deadline policy endpoints --- .../inspection-deadline-admin.controller.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts diff --git a/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts b/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts new file mode 100644 index 0000000..4869ec6 --- /dev/null +++ b/api-v3/src/inspection-reports/inspection-deadline-admin.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Get, Param, Put, Req } from '@nestjs/common'; +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 { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto'; +import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service'; + +@Controller('inspection-deadlines') +export class InspectionDeadlineAdminController { + constructor(private readonly deadlines: InspectionDeadlineAdminService) {} + + @Get('policy') + @RequirePermissions('inspections.read') + policy() { + return this.deadlines.policy(); + } + + @Put('policy') + @RequirePermissions('inspections.manage') + updatePolicy( + @Body() dto: UpdateDeadlinePolicyDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.updatePolicy(dto, principal, request); + } + + @Get('calendar') + @RequirePermissions('inspections.read') + calendar() { + return this.deadlines.calendar(); + } + + @Put('calendar') + @RequirePermissions('inspections.manage') + upsertDay( + @Body() dto: UpsertBusinessCalendarDayDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.upsertCalendarDay(dto, principal, request); + } + + @Delete('calendar/:date') + @RequirePermissions('inspections.manage') + removeDay( + @Param('date') date: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.deadlines.removeCalendarDay(date, principal, request); + } +} From e25f72ac0aff92b22261125f8b5701da279858fc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:03:00 -0300 Subject: [PATCH 037/224] feat(f4): register deadline administration --- api-v3/src/inspection-reports/inspection-reports.module.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index ff43f14..6f5d076 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { DocumentDeliveryController } from './document-delivery.controller'; import { InspectionActPdfService } from './inspection-act-pdf.service'; +import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller'; +import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWordService } from './inspection-report-word.service'; @@ -14,6 +16,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; controllers: [ InspectionReportsController, InspectionActReportController, + InspectionDeadlineAdminController, DocumentDeliveryController, ], providers: [ @@ -21,6 +24,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionReportWorkflowService, InspectionReportWordService, InspectionActPdfService, + InspectionDeadlineAdminService, InspectionDocumentDeliveryService, SmtpDeliveryService, ], From 5c1c046be6657576bf410cd37194bdc165896eaa Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:12:43 -0300 Subject: [PATCH 038/224] F4: add temporal inventory function catalog --- ...000100000-f4-inventory-function-history.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts diff --git a/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts b/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts new file mode 100644 index 0000000..c87b657 --- /dev/null +++ b/api-v3/src/database/migrations/1790000100000-f4-inventory-function-history.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface { + name = 'F4InventoryFunctionHistory1790000100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inventory_functions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(120) NOT NULL UNIQUE, + name varchar(240) NOT NULL, + description text, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_by uuid, + updated_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by) + REFERENCES users(id) ON DELETE SET NULL + ) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_functions_active_sort + ON inventory_functions(is_active,sort_order,name) + `); + + await queryRunner.query(` + CREATE TABLE inventory_function_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id uuid NOT NULL, + function_id uuid NOT NULL, + valid_from timestamptz NOT NULL, + valid_until timestamptz, + change_reason text, + changed_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id) + REFERENCES assets(id) ON DELETE CASCADE, + CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id) + REFERENCES inventory_functions(id) ON DELETE RESTRICT, + CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_inventory_function_assignment_dates CHECK ( + valid_until IS NULL OR valid_until > valid_from + ) + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inventory_function_assignment_current + ON inventory_function_assignments(asset_id) + WHERE valid_until IS NULL + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_function_assignment_history + ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_inventory_function_assignment_function + ON inventory_function_assignments(function_id,valid_from DESC) + `); + + await queryRunner.query(` + INSERT INTO inventory_functions(code,name,description,sort_order) + VALUES + ('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB', + 'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10), + ('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex', + 'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20) + ON CONFLICT (code) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments'); + await queryRunner.query('DROP TABLE IF EXISTS inventory_functions'); + } +} From 8b4cc99e5319f8c971c20037b426a9d8a378ead6 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:12:57 -0300 Subject: [PATCH 039/224] F4: add inventory function DTOs --- .../dto/inventory-function.dto.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 api-v3/src/asset-master/dto/inventory-function.dto.ts diff --git a/api-v3/src/asset-master/dto/inventory-function.dto.ts b/api-v3/src/asset-master/dto/inventory-function.dto.ts new file mode 100644 index 0000000..b47e34a --- /dev/null +++ b/api-v3/src/asset-master/dto/inventory-function.dto.ts @@ -0,0 +1,79 @@ +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsISO8601, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateInventoryFunctionDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') : value) + @IsString() + @MinLength(2) + @MaxLength(120) + code!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(240) + name!: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100000) + sortOrder?: number; +} + +export class UpdateInventoryFunctionDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(240) + name?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + @Max(100000) + sortOrder?: number; +} + +export class ChangeInventoryFunctionDto { + @IsUUID('4') + functionId!: string; + + @IsOptional() + @IsISO8601({ strict: true }) + effectiveAt?: string; + + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) + @IsString() + @MaxLength(4000) + reason?: string | null; +} From cbe69a8fffaa4f88e0c94594ab13a7c6fb7ad30b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:13:59 -0300 Subject: [PATCH 040/224] F4: implement temporal inventory function changes --- .../inventory-function.service.ts | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 api-v3/src/asset-master/inventory-function.service.ts diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts new file mode 100644 index 0000000..7cdd581 --- /dev/null +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -0,0 +1,352 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction, AssetVersionChangeType } from '../database/entities'; +import { AssetHistoryService } from './asset-history.service'; +import type { + ChangeInventoryFunctionDto, + CreateInventoryFunctionDto, + UpdateInventoryFunctionDto, +} from './dto/inventory-function.dto'; + +interface InventoryFunctionRow { + id: string; + code: string; + name: string; + description: string | null; + isActive: boolean; + sortOrder: number; + createdAt: Date; + updatedAt: Date; +} + +interface FunctionAssignmentRow { + id: string; + assetId: string; + functionId: string; + functionCode: string; + functionName: string; + validFrom: Date; + validUntil: Date | null; + reason: string | null; + changedBy: string | null; + changedByUsername: string | null; + createdAt: Date; +} + +interface FunctionEligibleAsset { + id: string; + code: string; + name: string; + typeCode: string; + typeName: string; + familyCode: string | null; + familyName: string | null; +} + +function normalized(value: string | null): string { + return (value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +@Injectable() +export class InventoryFunctionService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> { + const data = await this.dataSource.query(` + SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + FROM inventory_functions + ${includeInactive ? '' : 'WHERE is_active=true'} + ORDER BY sort_order,name,code + `) as InventoryFunctionRow[]; + return { data }; + } + + async create( + dto: CreateInventoryFunctionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const [duplicate] = await manager.query( + 'SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', + [dto.code], + ) as unknown[]; + if (duplicate) { + throw new ConflictException({ + code: 'INVENTORY_FUNCTION_CODE_EXISTS', + message: 'Ya existe una función con ese código', + }); + } + const [created] = await manager.query(` + INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by) + VALUES($1,$2,$3,true,$4,$5,$5) + RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + `, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[]; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INVENTORY_FUNCTION_CREATED, + entityType: 'inventory_function', + entityId: created.id, + afterData: created as unknown as Record, + }, manager); + return created; + }); + } + + async update( + id: string, + dto: UpdateInventoryFunctionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ): Promise { + if (Object.keys(dto).length === 0) { + throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); + } + return this.dataSource.transaction(async (manager) => { + const before = await this.requireFunction(manager, id, false); + const [updated] = await manager.query(` + UPDATE inventory_functions SET + name=COALESCE($2,name), + description=CASE WHEN $3::boolean THEN $4 ELSE description END, + is_active=COALESCE($5,is_active), + sort_order=COALESCE($6,sort_order), + updated_by=$7,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + `, [ + id, + dto.name ?? null, + dto.description !== undefined, + dto.description ?? null, + dto.isActive ?? null, + dto.sortOrder ?? null, + principal.userId, + ]) as InventoryFunctionRow[]; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INVENTORY_FUNCTION_UPDATED, + entityType: 'inventory_function', + entityId: id, + beforeData: before as unknown as Record, + afterData: updated as unknown as Record, + }, manager); + return updated; + }); + } + + async getForAsset(assetId: string) { + return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId)); + } + + async changeForAsset( + assetId: string, + dto: ChangeInventoryFunctionDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const asset = await this.requireEligibleAsset(manager, assetId, true); + const nextFunction = await this.requireFunction(manager, dto.functionId, true); + const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date(); + if (!Number.isFinite(effectiveAt.getTime())) { + throw new BadRequestException({ + code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', + message: 'La fecha efectiva del cambio de función no es válida', + }); + } + if (effectiveAt.getTime() > Date.now() + 60_000) { + throw new BadRequestException({ + code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', + message: 'El cambio de función no puede registrarse con fecha futura', + }); + } + + const current = await this.currentAssignment(manager, assetId, true); + if (current?.functionId === nextFunction.id) { + return this.getForAssetWithManager(manager, assetId); + } + if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) { + throw new ConflictException({ + code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', + message: 'La fecha efectiva debe ser posterior al inicio de la función vigente', + }); + } + + if (current) { + await manager.query(` + UPDATE inventory_function_assignments + SET valid_until=$2 + WHERE id=$1 AND valid_until IS NULL + `, [current.id, effectiveAt]); + } + await manager.query(` + INSERT INTO inventory_function_assignments( + asset_id,function_id,valid_from,change_reason,changed_by + ) VALUES($1,$2,$3,$4,$5) + `, [assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]); + await manager.query(` + UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1 + `, [assetId, principal.userId]); + const versionNumber = await this.history.capture( + manager, + assetId, + AssetVersionChangeType.FUNCTION_CHANGED, + principal, + request, + ); + const after = await this.getForAssetWithManager(manager, assetId); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_FUNCTION_CHANGED, + entityType: 'asset', + entityId: assetId, + beforeData: current ? { + functionId: current.functionId, + functionCode: current.functionCode, + functionName: current.functionName, + validFrom: current.validFrom, + } : { functionId: null }, + afterData: { + functionId: nextFunction.id, + functionCode: nextFunction.code, + functionName: nextFunction.name, + effectiveAt, + reason: dto.reason ?? null, + }, + metadata: { + inventoryCode: asset.code, + inventoryName: asset.name, + versionNumber, + temporal: true, + }, + }, manager); + return after; + }); + } + + private async getForAssetWithManager(manager: EntityManager, assetId: string) { + const asset = await this.requireEligibleAsset(manager, assetId, false); + const currentFunction = await this.currentAssignment(manager, assetId, false); + const history = await manager.query(` + SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", + fn.code AS "functionCode",fn.name AS "functionName", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + WHERE assignment.asset_id=$1 + ORDER BY assignment.valid_from DESC,assignment.created_at DESC + `, [assetId]) as FunctionAssignmentRow[]; + return { + asset: { + id: asset.id, + code: asset.code, + name: asset.name, + typeCode: asset.typeCode, + typeName: asset.typeName, + familyCode: asset.familyCode, + familyName: asset.familyName, + }, + currentFunction, + history, + }; + } + + private async currentAssignment( + manager: EntityManager, + assetId: string, + lock: boolean, + ): Promise { + const [row] = await manager.query(` + SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", + fn.code AS "functionCode",fn.name AS "functionName", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL + ${lock ? 'FOR UPDATE OF assignment' : ''} + `, [assetId]) as FunctionAssignmentRow[]; + return row ?? null; + } + + private async requireFunction( + manager: EntityManager, + id: string, + active: boolean, + ): Promise { + const [row] = await manager.query(` + SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", + created_at AS "createdAt",updated_at AS "updatedAt" + FROM inventory_functions + WHERE id=$1 ${active ? 'AND is_active=true' : ''} + `, [id]) as InventoryFunctionRow[]; + if (!row) { + throw new NotFoundException({ + code: 'INVENTORY_FUNCTION_NOT_FOUND', + message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada', + }); + } + return row; + } + + private async requireEligibleAsset( + manager: EntityManager, + assetId: string, + lock: boolean, + ): Promise { + const [asset] = await manager.query(` + SELECT asset.id,asset.code,asset.name, + asset_type.code AS "typeCode",asset_type.name AS "typeName", + family.code AS "familyCode",family.name AS "familyName" + FROM assets asset + JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id + LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE asset.id=$1 + ${lock ? 'FOR UPDATE OF asset' : ''} + `, [assetId]) as FunctionEligibleAsset[]; + if (!asset) { + throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' }); + } + const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized); + const eligible = values.some((value) => + value === 'estacion' + || value === 'subestacion' + || value.includes('estacion ') + || value.includes('subestacion ') + || value.endsWith(' estacion') + || value.endsWith(' subestacion'), + ); + if (!eligible) { + throw new ConflictException({ + code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', + message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación', + }); + } + return asset; + } +} From e02a3d9b58919b13c9e38446810268ada0d8437a Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:14:09 -0300 Subject: [PATCH 041/224] F4: expose inventory function catalog and history --- .../inventory-function.controller.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 api-v3/src/asset-master/inventory-function.controller.ts diff --git a/api-v3/src/asset-master/inventory-function.controller.ts b/api-v3/src/asset-master/inventory-function.controller.ts new file mode 100644 index 0000000..618d5c4 --- /dev/null +++ b/api-v3/src/asset-master/inventory-function.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +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 { + ChangeInventoryFunctionDto, + CreateInventoryFunctionDto, + UpdateInventoryFunctionDto, +} from './dto/inventory-function.dto'; +import { InventoryFunctionService } from './inventory-function.service'; + +@Controller() +export class InventoryFunctionController { + constructor(private readonly functions: InventoryFunctionService) {} + + @Get('inventory-functions') + @RequirePermissions('assets.read') + list(@Query('includeInactive') includeInactive?: string) { + return this.functions.list(includeInactive === 'true'); + } + + @Post('inventory-functions') + @RequirePermissions('asset_types.manage') + create( + @Body() dto: CreateInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.create(dto, principal, request); + } + + @Patch('inventory-functions/:id') + @RequirePermissions('asset_types.manage') + update( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.update(id, dto, principal, request); + } + + @Get('assets/:id/function-history') + @RequirePermissions('assets.read_history') + history(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.functions.getForAsset(id); + } + + @Post('assets/:id/function') + @RequirePermissions('assets.update') + change( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: ChangeInventoryFunctionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.functions.changeForAsset(id, dto, principal, request); + } +} From 0833746e1d9407626cf4f9b1781215bd50204098 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:14:27 -0300 Subject: [PATCH 042/224] F4: wire inventory function workflow --- api-v3/src/asset-master/asset-master.module.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index b649323..f4484e3 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { InventoryMergeService } from './inventory-merge.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; +import { InventoryFunctionController } from './inventory-function.controller'; +import { InventoryFunctionService } from './inventory-function.service'; @Module({ imports: [AuditModule], @@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic AssetsController, InventoryStructureController, InventoryFamilyCatalogController, + InventoryFunctionController, InventoryMergeController, FieldInventoryMergeController, AssetGeometriesController, @@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic AssetsService, InventoryStructureService, InventoryFamilyCatalogService, + InventoryFunctionService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, @@ -67,6 +71,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic exports: [ AssetHistoryService, AssetsService, + InventoryFunctionService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, From 4d5c83732ecd9496c222fade5cd02b705b9c1858 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:14:42 -0300 Subject: [PATCH 043/224] F4: version inventory function changes --- api-v3/src/database/entities/asset-version.entity.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api-v3/src/database/entities/asset-version.entity.ts b/api-v3/src/database/entities/asset-version.entity.ts index 6c9e241..0f041be 100644 --- a/api-v3/src/database/entities/asset-version.entity.ts +++ b/api-v3/src/database/entities/asset-version.entity.ts @@ -5,6 +5,7 @@ export enum AssetVersionChangeType { CREATED = 'CREATED', UPDATED = 'UPDATED', CONTEXT_CHANGED = 'CONTEXT_CHANGED', + FUNCTION_CHANGED = 'FUNCTION_CHANGED', STATUS_CHANGED = 'STATUS_CHANGED', OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED', REGISTRY_UPDATED = 'REGISTRY_UPDATED', From 88179eb67cc08ba1be87a512195d541bdf9ff2bb Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:15:15 -0300 Subject: [PATCH 044/224] F4: audit inventory function changes --- api-v3/src/database/entities/audit-event.entity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api-v3/src/database/entities/audit-event.entity.ts b/api-v3/src/database/entities/audit-event.entity.ts index 6fabafe..42a616e 100644 --- a/api-v3/src/database/entities/audit-event.entity.ts +++ b/api-v3/src/database/entities/audit-event.entity.ts @@ -35,6 +35,9 @@ export enum AuditAction { ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED', ASSET_UPDATED = 'ASSET_UPDATED', ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED', + ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED', + INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED', + INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED', ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED', ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED', ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED', From a836c7535ad9f0b33f574834aab989d4e128d45f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:17:33 -0300 Subject: [PATCH 045/224] F4: include technical function in merged inventory dossier --- .../merged-inventory-dossier.service.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/api-v3/src/asset-master/merged-inventory-dossier.service.ts b/api-v3/src/asset-master/merged-inventory-dossier.service.ts index 7a09038..7309767 100644 --- a/api-v3/src/asset-master/merged-inventory-dossier.service.ts +++ b/api-v3/src/asset-master/merged-inventory-dossier.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { AssetsService } from './assets.service'; +import { InventoryFunctionService } from './inventory-function.service'; import { InventoryMergeService } from './inventory-merge.service'; type LooseRecord = Record; @@ -29,6 +30,7 @@ export class MergedInventoryDossierService { constructor( private readonly assets: AssetsService, private readonly merges: InventoryMergeService, + private readonly functions: InventoryFunctionService, ) {} async dossier(requestedAssetId: string): Promise> { @@ -44,7 +46,8 @@ export class MergedInventoryDossierService { const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossier = await this.assets.dossier(assetId) as LooseRecord; const identity = dossier.asset as LooseRecord; - return { assetId, identity, dossier }; + const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; + return { assetId, identity, dossier, functionDossier }; })); const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({ @@ -75,6 +78,15 @@ export class MergedInventoryDossierService { const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']); const versions = sortDesc(collect('versions'), ['occurredAt']); + const functionHistory = sortDesc( + dossiers.flatMap(({ identity, functionDossier }) => + (((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))), + ), + ['validFrom', 'createdAt'], + ); + const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null; + const currentFunction = canonicalFunctionDossier?.currentFunction ?? null; + const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) => ((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({ ...event, @@ -91,6 +103,25 @@ export class MergedInventoryDossierService { ); const timeline: LooseRecord[] = dedupeById(timelineSource); + for (const assignment of functionHistory) { + timeline.push({ + id: `function:${String(assignment.id)}`, + kind: 'FUNCTION_CHANGED', + occurredAt: assignment.validFrom, + title: `Cambio de función · ${String(assignment.functionName)}`, + description: assignment.reason ?? null, + meta: { + functionId: assignment.functionId, + functionCode: assignment.functionCode, + functionName: assignment.functionName, + validFrom: assignment.validFrom, + validUntil: assignment.validUntil, + changedByUsername: assignment.changedByUsername, + historicalInventory: assignment.historicalInventory, + }, + }); + } + for (const alias of aliases) { timeline.push({ id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`, @@ -121,6 +152,7 @@ export class MergedInventoryDossierService { code: canonical.code, name: canonical.name, commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null, + currentFunction, }, requestedAsset: { id: requested.id, @@ -146,7 +178,10 @@ export class MergedInventoryDossierService { documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, reports: reports.length + inspectionReports.length, + functionChanges: functionHistory.length, }, + currentFunction, + functionHistory, visits, acts, findings, From c3c798baed8c671e0e5287078b3d95997b3dea91 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:17:46 -0300 Subject: [PATCH 046/224] F4: require explicit Act for every field finding --- .../src/inspection-visits/dto/create-field-finding.dto.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts index 8adab5f..abbce3a 100644 --- a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts @@ -18,12 +18,11 @@ const optionalText = ({ value }: { value: unknown }) => /** * Hallazgo capturado desde la APK sobre un Inventario ya seleccionado. * El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload. - * `actId` queda opcional sólo para compatibilidad con Android 0.11.0; F3.2 siempre lo envía. + * El Acta es obligatoria: nunca se infiere ni se adivina en backend. */ export class CreateFieldFindingDto { - @IsOptional() @IsUUID('4') - actId?: string; + actId!: string; @IsOptional() @Transform(optionalText) @@ -43,6 +42,7 @@ export class CreateFieldFindingDto { @MaxLength(12000) customLegalBasis?: string | null; + /** Descripción libre escrita por el inspector. Nunca se reemplaza por el catálogo. */ @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsString() @MinLength(1) From 2b63638c357d0dade20541092b984623b182656f Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:17:56 -0300 Subject: [PATCH 047/224] F4: remove legacy Act inference from finding queries --- .../dto/field-finding-act-query.dto.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts index 5df166f..0a570fc 100644 --- a/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts +++ b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts @@ -1,12 +1,7 @@ -import { IsOptional, IsUUID } from 'class-validator'; +import { IsUUID } from 'class-validator'; -/** - * Selección explícita del Acta activa desde la APK. - * `actId` queda opcional sólo durante la transición desde Android 0.11.0; - * F3.2 móvil siempre lo informa. - */ +/** Selección explícita y obligatoria del Acta activa desde la APK. */ export class FieldFindingActQueryDto { - @IsOptional() @IsUUID('4') - actId?: string; + actId!: string; } From 83ab2d4060b0073ad658822276c2ff28173b156b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:18:43 -0300 Subject: [PATCH 048/224] F4: add explicit inspection cancellation DTO --- .../dto/cancel-inspection-visit.dto.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts diff --git a/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts new file mode 100644 index 0000000..4116c81 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/cancel-inspection-visit.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class CancelInspectionVisitDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(4000) + reason!: string; +} From 577e267f498c48ca45ff8b0e17d16f43d2aa22be Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:19:12 -0300 Subject: [PATCH 049/224] F4: implement explicit inspection lifecycle --- .../inspection-visit-lifecycle.service.ts | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts diff --git a/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts new file mode 100644 index 0000000..e420083 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts @@ -0,0 +1,297 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; +import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; +import { InspectionVisitsService } from './inspection-visits.service'; + +interface PendingActRow { + id: string; + code: string; + status: string; +} + +@Injectable() +export class InspectionVisitLifecycleService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly visits: InspectionVisitsService, + ) {} + + async plan( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.DRAFT) { + throw new ConflictException({ + code: 'INSPECTION_NOT_DRAFT', + message: 'Sólo una Inspección en borrador puede planificarse', + }); + } + await this.validatePlanningContext(manager, visit); + const before = visit.status; + visit.status = InspectionVisitStatus.PLANNED; + visit.cancellationReason = null; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: before }, + afterData: { status: InspectionVisitStatus.PLANNED }, + metadata: { transition: 'PLAN' }, + }, manager); + }); + return this.visits.getById(id); + } + + async start( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.PLANNED) { + throw new ConflictException({ + code: 'INSPECTION_NOT_PLANNED', + message: 'La Inspección debe estar planificada antes de iniciarse', + }); + } + await this.validatePlanningContext(manager, visit); + await this.assertActorAssigned(manager, visit, principal); + const startedAt = new Date(); + visit.status = InspectionVisitStatus.IN_PROGRESS; + visit.actualStartedAt = startedAt; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STARTED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null }, + afterData: { status: InspectionVisitStatus.IN_PROGRESS, actualStartedAt: startedAt }, + metadata: { transition: 'START', source: 'ANDROID' }, + }, manager); + }); + return this.visits.getById(id); + } + + async close( + id: string, + dto: CloseInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_NOT_IN_PROGRESS', + message: 'La Inspección debe estar en curso para cerrarse', + }); + } + await this.assertActorAssigned(manager, visit, principal); + + const acts = await manager.query(` + SELECT id,code,status + FROM inspection_acts + WHERE visit_id=$1 AND status<>'CANCELLED' + ORDER BY occurred_at,created_at,id + FOR UPDATE + `, [id]) as PendingActRow[]; + if (acts.length === 0) { + throw new ConflictException({ + code: 'INSPECTION_REQUIRES_ACT', + message: 'La Inspección debe tener al menos un Acta antes de cerrarse', + }); + } + const pending = acts.filter((act) => act.status !== 'SEALED'); + if (pending.length > 0) { + throw new ConflictException({ + code: 'INSPECTION_ACTS_NOT_SEALED', + message: 'Todas las Actas deben estar SELLADAS antes de cerrar la Inspección', + pendingActs: pending.map((act) => ({ id: act.id, code: act.code, status: act.status })), + }); + } + + const serverClosedAt = new Date(); + const clientClosedAt = new Date(dto.clientClosedAt); + if (!Number.isFinite(clientClosedAt.getTime())) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_TIME_INVALID', + message: 'La fecha de cierre informada por el dispositivo no es válida', + }); + } + if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_BEFORE_START', + message: 'La fecha de cierre no puede ser anterior al inicio de la Inspección', + }); + } + if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INSPECTION_CLOSE_TIME_IN_FUTURE', + message: 'La fecha del 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); + 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(), + sealedActs: acts.length, + }, + metadata: { + transition: 'CLOSE', + source: 'ANDROID', + allActsSealed: true, + }, + }, manager); + }); + return this.visits.getById(id); + } + + async cancel( + id: string, + dto: CancelInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + await this.dataSource.transaction(async (manager) => { + const visit = await this.lockVisit(manager, id); + if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED, InspectionVisitStatus.IN_PROGRESS].includes(visit.status)) { + throw new ConflictException({ + code: 'INSPECTION_CANNOT_CANCEL', + message: 'La Inspección ya no puede cancelarse en su estado actual', + }); + } + const [sealedAct] = await manager.query(` + SELECT 1 FROM inspection_acts + WHERE visit_id=$1 AND status='SEALED' + LIMIT 1 + `, [id]) as unknown[]; + if (sealedAct) { + throw new ConflictException({ + code: 'INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL', + message: 'Una Inspección con Actas SELLADAS no puede cancelarse', + }); + } + const before = visit.status; + visit.status = InspectionVisitStatus.CANCELLED; + visit.cancellationReason = dto.reason; + visit.updatedBy = principal.userId; + await manager.getRepository(InspectionVisit).save(visit); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: before }, + afterData: { status: InspectionVisitStatus.CANCELLED, cancellationReason: dto.reason }, + metadata: { transition: 'CANCEL' }, + }, manager); + }); + return this.visits.getById(id); + } + + private async lockVisit(manager: EntityManager, id: string): Promise { + const visit = await manager.getRepository(InspectionVisit) + .createQueryBuilder('visit') + .where('visit.id=:id', { id }) + .setLock('pessimistic_write') + .getOne(); + if (!visit) { + throw new NotFoundException({ code: 'INSPECTION_NOT_FOUND', message: 'Inspección no encontrada' }); + } + return visit; + } + + private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise { + if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) { + throw new ConflictException({ + code: 'INSPECTION_PLANNING_INCOMPLETE', + message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar', + }); + } + const [relation] = await manager.query(` + SELECT 1 + FROM area_company_relations + WHERE area_id=$1 AND company_id=$2 + AND relation_role='OPERATOR' + AND valid_from<=CURRENT_TIMESTAMP + AND (valid_until IS NULL OR valid_until>CURRENT_TIMESTAMP) + LIMIT 1 + `, [visit.operationalAreaId, visit.operatorCompanyId]) as unknown[]; + if (!relation) { + throw new ConflictException({ + code: 'INSPECTION_OPERATOR_RELATION_INVALID', + message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento', + }); + } + const [inspector] = await manager.query(` + SELECT 1 + FROM users user_account + JOIN user_roles user_role ON user_role.user_id=user_account.id + JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id + JOIN permissions permission ON permission.id=role_permission.permission_id + WHERE user_account.id=$1 AND user_account.status='ACTIVE' + AND permission.code='inspections.execute' + LIMIT 1 + `, [visit.leadInspectorUserId]) as unknown[]; + if (!inspector) { + throw new ConflictException({ + code: 'INSPECTION_LEAD_INVALID', + message: 'El Inspector responsable no está activo o no puede ejecutar Inspecciones', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visit: InspectionVisit, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [member] = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true + LIMIT 1 + `, [visit.id, principal.userId]) as unknown[]; + if (!member && visit.leadInspectorUserId !== principal.userId) { + throw new ForbiddenException({ + code: 'INSPECTION_NOT_ASSIGNED', + message: 'La Inspección no está asignada al usuario actual', + }); + } + } +} From fcf52c60ff1a3864a14b06804368cb1589b9e4f1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:19:22 -0300 Subject: [PATCH 050/224] F4: wire explicit inspection lifecycle --- api-v3/src/inspection-visits/inspection-visits.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts index 46e3326..0c48c7f 100644 --- a/api-v3/src/inspection-visits/inspection-visits.module.ts +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -7,6 +7,7 @@ import { FieldFindingsController } from './field-findings.controller'; import { FieldFindingsService } from './field-findings.service'; import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryService } from './field-inventory.service'; +import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsService } from './inspection-visits.service'; @@ -15,6 +16,7 @@ import { InspectionVisitsService } from './inspection-visits.service'; controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], providers: [ InspectionVisitsService, + InspectionVisitLifecycleService, FieldInventoryService, F3FieldInventoryStructureService, FieldFindingsService, From 5e14ffda17870445cae715704d9899755af05c42 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:19:42 -0300 Subject: [PATCH 051/224] F4: expose explicit inspection lifecycle endpoints --- .../inspection-visits.controller.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 5fb0c86..4ded255 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -13,7 +13,7 @@ import { 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 { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.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'; @@ -21,11 +21,15 @@ import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; +import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitsService } from './inspection-visits.service'; @Controller('inspection-visits') export class InspectionVisitsController { - constructor(private readonly visits: InspectionVisitsService) {} + constructor( + private readonly visits: InspectionVisitsService, + private readonly lifecycle: InspectionVisitLifecycleService, + ) {} @Get() @RequirePermissions('inspections.read') @@ -135,15 +139,14 @@ export class InspectionVisitsController { return this.visits.replaceTeam(id, dto, principal, request); } - @Put(':id/status') + @Post(':id/plan') @RequirePermissions('inspections.manage') - changeStatus( + plan( @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeInspectionVisitStatusDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.changeStatus(id, dto, principal, request); + return this.lifecycle.plan(id, principal, request); } @Post(':id/start') @@ -153,7 +156,7 @@ export class InspectionVisitsController { @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.start(id, principal, request); + return this.lifecycle.start(id, principal, request); } @Post(':id/close') @@ -164,6 +167,17 @@ export class InspectionVisitsController { @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext, ) { - return this.visits.close(id, dto, principal, request); + return this.lifecycle.close(id, dto, principal, request); + } + + @Post(':id/cancel') + @RequirePermissions('inspections.manage') + cancel( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: CancelInspectionVisitDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.lifecycle.cancel(id, dto, principal, request); } } From 0a8cb8f8e5f56b33f5fce8aec455d119b06b0782 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:20:31 -0300 Subject: [PATCH 052/224] F4: add finding recurrence DTO --- .../inspection-findings/dto/set-finding-recurrence.dto.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts diff --git a/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts b/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts new file mode 100644 index 0000000..7801e67 --- /dev/null +++ b/api-v3/src/inspection-findings/dto/set-finding-recurrence.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class SetFindingRecurrenceDto { + @IsUUID('4') + recurrenceOfFindingId!: string; +} From af6256ce0fcbdf89d492dcadd2b44834aba7923b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:20:56 -0300 Subject: [PATCH 053/224] F4: implement finding recurrence workflow --- .../finding-recurrence.service.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 api-v3/src/inspection-findings/finding-recurrence.service.ts diff --git a/api-v3/src/inspection-findings/finding-recurrence.service.ts b/api-v3/src/inspection-findings/finding-recurrence.service.ts new file mode 100644 index 0000000..a3e7c3e --- /dev/null +++ b/api-v3/src/inspection-findings/finding-recurrence.service.ts @@ -0,0 +1,232 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto'; +import { InspectionFindingsService } from './inspection-findings.service'; + +interface FindingRecurrenceContext { + id: string; + assetId: string; + catalogItemId: string | null; + title: string; + createdAt: Date; + actId: string; + actStatus: string; + visitId: string; + visitStatus: string; +} + +function comparable(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +@Injectable() +export class FindingRecurrenceService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly findings: InspectionFindingsService, + ) {} + + async candidates(id: string) { + const current = await this.requireFinding(this.dataSource.manager, id, false); + const rows = await this.dataSource.query(` + SELECT + finding.id,finding.code,finding.title,finding.description,finding.status, + finding.catalog_item_id AS "catalogItemId",finding.created_at AS "createdAt", + act.id AS "actId",act.code AS "actCode",act.occurred_at AS "actOccurredAt", + visit.id AS "visitId",visit.code AS "visitCode",visit.status AS "visitStatus" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id=finding.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE finding.asset_id=$1 + AND finding.id<>$2 + AND finding.status<>'VOIDED' + AND finding.created_at<$3 + AND ( + ($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid) + OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5))) + ) + ORDER BY finding.created_at DESC,finding.id DESC + LIMIT 20 + `, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]); + return { + findingId: current.id, + assetId: current.assetId, + hasCandidates: rows.length > 0, + data: rows, + }; + } + + async link( + id: string, + dto: SetFindingRecurrenceDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const current = await this.requireFinding(manager, id, true); + this.assertEditable(current); + await this.assertActorAssigned(manager, current.visitId, principal); + if (id === dto.recurrenceOfFindingId) { + throw new BadRequestException({ + code: 'FINDING_CANNOT_RECUR_FROM_SELF', + message: 'Un Hallazgo no puede ser reincidencia de sí mismo', + }); + } + const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false); + if (previous.assetId !== current.assetId) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY', + message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario', + }); + } + if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_NOT_PREVIOUS', + message: 'El Hallazgo de referencia debe ser anterior al actual', + }); + } + const sameCatalog = current.catalogItemId && previous.catalogItemId + ? current.catalogItemId === previous.catalogItemId + : current.catalogItemId === previous.catalogItemId; + const sameTitle = comparable(current.title) === comparable(previous.title); + if (!sameCatalog && !sameTitle) { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_NOT_EQUIVALENT', + message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo', + }); + } + + const [before] = await manager.query(` + SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" + FROM inspection_findings WHERE id=$1 FOR UPDATE + `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; + await manager.query(` + UPDATE inspection_findings + SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [id, previous.id, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_RECURRENCE_LINKED, + entityType: 'inspection_finding', + entityId: id, + beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null }, + afterData: { + isRecurrence: true, + recurrenceOfFindingId: previous.id, + recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id), + }, + metadata: { actId: current.actId, visitId: current.visitId }, + }, manager); + }); + return this.findings.getById(id); + } + + async clear( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const current = await this.requireFinding(manager, id, true); + this.assertEditable(current); + await this.assertActorAssigned(manager, current.visitId, principal); + const [before] = await manager.query(` + SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" + FROM inspection_findings WHERE id=$1 FOR UPDATE + `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; + if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return; + await manager.query(` + UPDATE inspection_findings + SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [id, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_FINDING_RECURRENCE_CLEARED, + entityType: 'inspection_finding', + entityId: id, + beforeData: before, + afterData: { isRecurrence: false, recurrenceOfFindingId: null }, + metadata: { actId: current.actId, visitId: current.visitId }, + }, manager); + }); + return this.findings.getById(id); + } + + private async requireFinding( + manager: EntityManager, + id: string, + lock: boolean, + ): Promise { + const [row] = await manager.query(` + SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId", + finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId", + act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus" + FROM inspection_findings finding + JOIN inspection_acts act ON act.id=finding.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE finding.id=$1 + ${lock ? 'FOR UPDATE OF finding,act,visit' : ''} + `, [id]) as FindingRecurrenceContext[]; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_FINDING_NOT_FOUND', + message: 'Hallazgo no encontrado', + }); + } + return row; + } + + private assertEditable(finding: FindingRecurrenceContext): void { + if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') { + throw new ConflictException({ + code: 'FINDING_RECURRENCE_IMMUTABLE', + message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso', + }); + } + } + + private async assertActorAssigned( + manager: EntityManager, + visitId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [row] = await manager.query(` + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 + `, [visitId, principal.userId]) as unknown[]; + if (!row) { + throw new ForbiddenException({ + code: 'INSPECTION_NOT_ASSIGNED', + message: 'La Inspección no está asignada al usuario actual', + }); + } + } + + private async codeForFinding(manager: EntityManager, id: string): Promise { + const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>; + return row?.code ?? null; + } +} From 236605a7d7eced26d630559d3e9539ad49e28907 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:21:04 -0300 Subject: [PATCH 054/224] F4: expose finding recurrence endpoints --- .../finding-recurrence.controller.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 api-v3/src/inspection-findings/finding-recurrence.controller.ts diff --git a/api-v3/src/inspection-findings/finding-recurrence.controller.ts b/api-v3/src/inspection-findings/finding-recurrence.controller.ts new file mode 100644 index 0000000..c06e773 --- /dev/null +++ b/api-v3/src/inspection-findings/finding-recurrence.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Req, +} from '@nestjs/common'; +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 { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto'; +import { FindingRecurrenceService } from './finding-recurrence.service'; + +@Controller('inspection-findings') +export class FindingRecurrenceController { + constructor(private readonly recurrence: FindingRecurrenceService) {} + + @Get(':id/recurrence-candidates') + @RequirePermissions('inspection_findings.read') + candidates(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.recurrence.candidates(id); + } + + @Post(':id/recurrence') + @RequirePermissions('inspection_findings.update') + link( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: SetFindingRecurrenceDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.recurrence.link(id, dto, principal, request); + } + + @Delete(':id/recurrence') + @RequirePermissions('inspection_findings.update') + clear( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.recurrence.clear(id, principal, request); + } +} From 8b033dc297e46411fb6d06210bb819531ac89939 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:21:14 -0300 Subject: [PATCH 055/224] F4: wire finding recurrence workflow --- api-v3/src/inspection-findings/inspection-findings.module.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api-v3/src/inspection-findings/inspection-findings.module.ts b/api-v3/src/inspection-findings/inspection-findings.module.ts index 6ba1fba..de913ca 100644 --- a/api-v3/src/inspection-findings/inspection-findings.module.ts +++ b/api-v3/src/inspection-findings/inspection-findings.module.ts @@ -4,6 +4,8 @@ import { FindingCatalogController } from './finding-catalog.controller'; import { FindingCatalogMergeService } from './finding-catalog-merge.service'; import { FindingCatalogService } from './finding-catalog.service'; import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service'; +import { FindingRecurrenceController } from './finding-recurrence.controller'; +import { FindingRecurrenceService } from './finding-recurrence.service'; import { InspectionActFindingsController, InspectionFindingsController, @@ -22,6 +24,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; FindingCatalogController, InspectionActFindingsController, InspectionFindingsController, + FindingRecurrenceController, InspectionFindingEvidenceController, InspectionFindingCommunicationsController, InspectionEvidenceContentController, @@ -31,6 +34,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; F3FindingCatalogResolverService, FindingCatalogMergeService, InspectionFindingsService, + FindingRecurrenceService, InspectionEvidenceService, ], exports: [ @@ -38,6 +42,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service'; F3FindingCatalogResolverService, FindingCatalogMergeService, InspectionFindingsService, + FindingRecurrenceService, ], }) export class InspectionFindingsModule {} From c30f167731e61bbf6697c638be19ac295e8c68bf Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:21:46 -0300 Subject: [PATCH 056/224] F4: audit recurrence through finding update ledger --- .../src/inspection-findings/finding-recurrence.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api-v3/src/inspection-findings/finding-recurrence.service.ts b/api-v3/src/inspection-findings/finding-recurrence.service.ts index a3e7c3e..c20f384 100644 --- a/api-v3/src/inspection-findings/finding-recurrence.service.ts +++ b/api-v3/src/inspection-findings/finding-recurrence.service.ts @@ -126,7 +126,7 @@ export class FindingRecurrenceService { `, [id, previous.id, principal.userId]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_FINDING_RECURRENCE_LINKED, + action: AuditAction.INSPECTION_FINDING_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null }, @@ -135,7 +135,7 @@ export class FindingRecurrenceService { recurrenceOfFindingId: previous.id, recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id), }, - metadata: { actId: current.actId, visitId: current.visitId }, + metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' }, }, manager); }); return this.findings.getById(id); @@ -163,12 +163,12 @@ export class FindingRecurrenceService { `, [id, principal.userId]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_FINDING_RECURRENCE_CLEARED, + action: AuditAction.INSPECTION_FINDING_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: before, afterData: { isRecurrence: false, recurrenceOfFindingId: null }, - metadata: { actId: current.actId, visitId: current.visitId }, + metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' }, }, manager); }); return this.findings.getById(id); From 009f4d10cfed3093473393e32b8610b5b4cb212a Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:22:53 -0300 Subject: [PATCH 057/224] F4: keep function history in its temporal ledger --- .../src/asset-master/inventory-function.service.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts index 7cdd581..8b5c644 100644 --- a/api-v3/src/asset-master/inventory-function.service.ts +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -8,8 +8,7 @@ import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { AuditAction, AssetVersionChangeType } from '../database/entities'; -import { AssetHistoryService } from './asset-history.service'; +import { AuditAction } from '../database/entities'; import type { ChangeInventoryFunctionDto, CreateInventoryFunctionDto, @@ -65,7 +64,6 @@ export class InventoryFunctionService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, - private readonly history: AssetHistoryService, ) {} async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> { @@ -207,13 +205,6 @@ export class InventoryFunctionService { await manager.query(` UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [assetId, principal.userId]); - const versionNumber = await this.history.capture( - manager, - assetId, - AssetVersionChangeType.FUNCTION_CHANGED, - principal, - request, - ); const after = await this.getForAssetWithManager(manager, assetId); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -236,8 +227,8 @@ export class InventoryFunctionService { metadata: { inventoryCode: asset.code, inventoryName: asset.name, - versionNumber, temporal: true, + historySource: 'inventory_function_assignments', }, }, manager); return after; From d0c55b8b39398ac5d8dc36f89d8f5cf6b3a9285d Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:25:53 -0300 Subject: [PATCH 058/224] F4 Android: align Act contracts with lock and seal --- .../korexlabs/dhinspeccion/data/MobileActs.kt | 68 +++++++++++++------ 1 file changed, 46 insertions(+), 22 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index 22bb6eb..a3a40ef 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -37,6 +37,15 @@ data class MobileActSummary( val title: String, val summary: String, val observations: String? = null, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -53,6 +62,15 @@ data class MobileActDetail( val title: String, val summary: String, val observations: String? = null, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -75,6 +93,7 @@ data class MobileActListResponse( data class CreateMobileActRequest( val occurredAt: String, + val urgency: String, val title: String, val summary: String, val observations: String? = null, @@ -113,6 +132,15 @@ data class MobileActClosureHeader( val code: String, val status: String, val visitId: String, + val urgency: String = "NON_URGENT", + val deadlineDays: Int? = null, + val deadlineDayType: String? = null, + val deadlineBasis: String? = null, + val deadlineBaseAt: String? = null, + val deadlineAt: String? = null, + val lockedAt: String? = null, + val lockedSha256: String? = null, + val sealedAt: String? = null, val currentVersion: Int = 0, val closedAt: String? = null, val closureSha256: String? = null, @@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest( val reason: String, ) -data class MobileCloseActRequest( +data class MobileSealActRequest( val clientClosedAt: String = Instant.now().toString(), val uploadMode: String = "ONLINE", ) @@ -219,14 +247,8 @@ private interface MobileActsApi { @Body request: MobileResponsibleRequest, ): MobileActClosure - @POST("inspection-acts/{actId}/ready") - suspend fun ready( - @Header("Authorization") authorization: String, - @Path("actId") actId: String, - ): MobileActClosure - - @POST("inspection-acts/{actId}/reopen") - suspend fun reopen( + @POST("inspection-acts/{actId}/lock") + suspend fun lock( @Header("Authorization") authorization: String, @Path("actId") actId: String, ): MobileActClosure @@ -268,11 +290,11 @@ private interface MobileActsApi { @Body request: MobileCompanyOutcomeRequest, ): MobileActClosure - @POST("inspection-acts/{actId}/close") - suspend fun closeAct( + @POST("inspection-acts/{actId}/seal") + suspend fun sealAct( @Header("Authorization") authorization: String, @Path("actId") actId: String, - @Body request: MobileCloseActRequest, + @Body request: MobileSealActRequest, ): MobileActClosure @POST("inspection-visits/{visitId}/close") @@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) { api.act("Bearer ${session.accessToken}", actId) } - suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session -> + suspend fun create( + visitId: String, + assetId: String, + visitCode: String, + urgency: String, + ): MobileActDetail = authorized { session -> api.createAct( "Bearer ${session.accessToken}", visitId, CreateMobileActRequest( occurredAt = Instant.now().toString(), + urgency = urgency, title = "Acta de inspección $visitCode", - summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.", + summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", assetIds = listOf(assetId), ), ) @@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) { api.responsible("Bearer ${session.accessToken}", actId, request) } - suspend fun prepare(actId: String): MobileActClosure = authorized { session -> - api.ready("Bearer ${session.accessToken}", actId) - } - - suspend fun reopen(actId: String): MobileActClosure = authorized { session -> - api.reopen("Bearer ${session.accessToken}", actId) + suspend fun lock(actId: String): MobileActClosure = authorized { session -> + api.lock("Bearer ${session.accessToken}", actId) } suspend fun signInspector( @@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) { ) } - suspend fun closeAct(actId: String): MobileActClosure = authorized { session -> - api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest()) + suspend fun seal(actId: String): MobileActClosure = authorized { session -> + api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) } suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> From d9698f11f0708efd44c055d32bb1088bbcf3dfdf Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:26:55 -0300 Subject: [PATCH 059/224] F4: remove legacy per-Finding company follow-up endpoint --- .../inspection-findings.controller.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/api-v3/src/inspection-findings/inspection-findings.controller.ts b/api-v3/src/inspection-findings/inspection-findings.controller.ts index 1540671..ce33ffe 100644 --- a/api-v3/src/inspection-findings/inspection-findings.controller.ts +++ b/api-v3/src/inspection-findings/inspection-findings.controller.ts @@ -15,7 +15,6 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; -import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto'; import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; import { InspectionFindingsService } from './inspection-findings.service'; @@ -84,15 +83,4 @@ export class InspectionFindingsController { ) { return this.findings.close(id, dto, principal, request); } - - @Patch(':id/follow-up') - @RequirePermissions('inspection_findings.follow_up') - updateFollowUp( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateInspectionFindingFollowUpDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.findings.updateFollowUp(id, dto, principal, request); - } } From 1bed1cae98012c6aded7a7271758c2f1ee429d46 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:28:53 -0300 Subject: [PATCH 060/224] F4 Android: keep branch compiling while UI migrates to lock/seal --- .../com/korexlabs/dhinspeccion/data/MobileActs.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index a3a40ef..3691826 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -331,7 +331,7 @@ class MobileActsRepository(context: Context) { visitId: String, assetId: String, visitCode: String, - urgency: String, + urgency: String = "NON_URGENT", ): MobileActDetail = authorized { session -> api.createAct( "Bearer ${session.accessToken}", @@ -367,6 +367,14 @@ class MobileActsRepository(context: Context) { api.lock("Bearer ${session.accessToken}", actId) } + /** Transitional alias used only while the F4 screen migration is in this branch. */ + suspend fun prepare(actId: String): MobileActClosure = lock(actId) + + /** F4 does not allow reopening an Act once it has been locked. */ + suspend fun reopen(@Suppress("UNUSED_PARAMETER") actId: String): MobileActClosure { + throw IllegalStateException("Un Acta finalizada es inmutable y no puede volver a borrador") + } + suspend fun signInspector( actId: String, png: File, @@ -406,6 +414,9 @@ class MobileActsRepository(context: Context) { api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) } + /** Transitional alias used only while the F4 screen migration is in this branch. */ + suspend fun closeAct(actId: String): MobileActClosure = seal(actId) + suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest()) } From f5ab6ce885358235aebb21f0644f196b066095ae Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:30:43 -0300 Subject: [PATCH 061/224] F4: rebuild Act PDF from locked and sealed snapshot --- .../inspection-act-pdf-builder.ts | 260 +++++++++++++----- 1 file changed, 192 insertions(+), 68 deletions(-) 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 7039abd..e058911 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -1,88 +1,212 @@ import { createHash } from 'node:crypto'; -function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } -function asArray(value: unknown): Array> { return Array.isArray(value) ? value.map(asRecord) : []; } -function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; } -function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; } -function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); } -function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); } -function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; } +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function asArray(value: unknown): Array> { + return Array.isArray(value) ? value.map(asRecord) : []; +} + +function text(value: unknown, fallback = '-'): string { + const out = String(value ?? '').trim(); + return out || fallback; +} + +function date(value: unknown): string { + const parsed = new Date(String(value ?? '')); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-'; +} + +function clean(value: string): string { + return value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[–—]/g, '-') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[^\x20-\xFF]/g, '?'); +} + +function escapePdf(value: string): string { + return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)'); +} + +function wrap(value: string, max = 92): string[] { + const words = clean(value).split(/\s+/).filter(Boolean); + const out: string[] = []; + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > max && line) { + out.push(line); + line = word; + } else { + line = next; + } + } + if (line) out.push(line); + return out.length ? out : ['-']; +} + +function lockedSnapshot(snapshot: Record): { + locked: Record; + signatures: Array>; + finalSha256: unknown; +} { + const sealed = asRecord(snapshot); + const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); + return { + locked, + signatures: asArray(sealed.signatures), + finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256, + }; +} + +function urgencyLabel(value: unknown): string { + return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; +} + +function dayTypeLabel(value: unknown): string { + return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles'; +} function lines(snapshot: Record): string[] { - const prepared=asRecord(asRecord(snapshot).preparedSnapshot); - const act=asRecord(prepared.act); - const visit=asRecord(act.visit); - const responsible=asRecord(prepared.responsible); - 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); - const out:string[]=[ + const source = lockedSnapshot(snapshot); + const locked = source.locked; + const act = asRecord(locked.act); + const inspection = asRecord(act.inspection ?? asRecord(act).visit); + const responsible = asRecord(locked.responsible); + const inventories = asArray(locked.inventories ?? locked.assets); + const findings = asArray(locked.findings); + const signatures = source.signatures; + const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE'); + const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR'); + + const deadlineText = act.deadlineAt + ? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})` + : act.deadlineBasis === 'GEDO_DATE' + ? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO` + : '-'; + + const out: string[] = [ 'ACTA DE INSPECCION', - 'Documento automatico - diseno institucional pendiente de definicion', '', `Acta: ${text(act.code)}`, - `Inspeccion: ${text(visit.code)}`, + `Inspeccion: ${text(inspection.code)}`, `Fecha: ${date(act.occurredAt)}`, - `Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`, - `Area: ${areas.join(' / ') || 'Segun alcance del acta'}`, - `Inspector/es: ${inspectors.join(' / ') || '-'}`, + `Urgencia: ${urgencyLabel(act.urgency)}`, + `Plazo: ${deadlineText}`, `Responsable empresa: ${text(responsible.fullName)}`, `Cargo: ${text(responsible.position)}`, '', - 'OBJETIVO', ...wrap(text(visit.objective)), '', - 'RESUMEN', ...wrap(text(act.summary)), '', - 'OBSERVACIONES', ...wrap(text(act.observations)), '', - 'ELEMENTOS INSPECCIONADOS', + 'RESUMEN', + ...wrap(text(act.summary)), + '', + 'OBSERVACIONES', + ...wrap(text(act.observations)), + '', + 'INVENTARIO INSPECCIONADO', ]; - if(!assets.length) out.push('-'); - for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`)); - 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)}`)); + + if (!inventories.length) out.push('-'); + for (const item of inventories) { + out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`)); } - out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); + + out.push('', 'HALLAZGOS'); + if (!findings.length) out.push('Sin hallazgos registrados.'); + for (const item of findings) { + const recurrence = item.isRecurrence + ? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}` + : ''; + out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`)); + out.push(...wrap(`Descripcion: ${text(item.description)}`)); + if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`)); + } + + out.push('', 'FIRMAS Y CONSTANCIAS'); + if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.'); + for (const signature of inspectorSignatures) { + out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`)); + } + if (!companySignature) { + out.push('Manifestacion de empresa: pendiente.'); + } else if (text(companySignature.status, '') === 'SIGNED') { + const manifestation = text(companySignature.companyManifestation, 'CONFORMITY'); + out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad'); + if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement))); + } else { + out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`)); + } + + out.push( + '', + 'INTEGRIDAD', + `Hash del Acta sellada: ${text(source.finalSha256)}`, + `Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`, + ); return out; } -function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); } +function objectBuffer(id: number, body: Buffer | string): Buffer { + const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1'); + return Buffer.concat([ + Buffer.from(`${id} 0 obj\n`, 'ascii'), + data, + Buffer.from('\nendobj\n', 'ascii'), + ]); +} export function buildInspectionActPdf(snapshot: Record): { buffer: Buffer; sha256: string } { - const all=lines(snapshot); - const chunks:Array=[]; - for(let i=0;i4+i*2); - const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2); - const objects:Buffer[]=[]; - objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>')); - objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`)); - objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); - chunks.forEach((chunk,index)=>{ - const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n'); - const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1'); - objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`)); - objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')]))); + const all = lines(snapshot); + const chunks: Array = []; + for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56)); + if (!chunks.length) chunks.push(['ACTA DE INSPECCION']); + + const pageCount = chunks.length; + const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2); + const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2); + const objects: Buffer[] = []; + objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>')); + objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`)); + objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')); + + chunks.forEach((chunk, index) => { + const content = chunk + .map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`) + .join('\n'); + const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1'); + objects.push(objectBuffer( + pageIds[index]!, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`, + )); + objects.push(objectBuffer( + contentIds[index]!, + Buffer.concat([ + Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'), + stream, + Buffer.from('\nendstream', 'ascii'), + ]), + )); }); - const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary'); - const offsets:number[]=[0]; let pos=header.length; - for(const obj of objects){ offsets.push(pos); pos+=obj.length; } - const xrefOffset=pos; - const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join(''); - const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]); - return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') }; + + const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'); + const offsets: number[] = [0]; + let position = header.length; + for (const object of objects) { + offsets.push(position); + position += object.length; + } + const xrefOffset = position; + const xref = [ + `xref\n0 ${objects.length + 1}\n`, + '0000000000 65535 f \n', + ...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`), + ].join(''); + const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]); + return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; } From 53702009e3cd024107d0deccaea534136844e0f1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:31:35 -0300 Subject: [PATCH 062/224] F4: rebuild editable INF Word from sealed Act snapshot --- .../inspection-report-word-builder.ts | 130 ++++++++++++------ 1 file changed, 89 insertions(+), 41 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-report-word-builder.ts b/api-v3/src/inspection-reports/inspection-report-word-builder.ts index 387e270..27f30b8 100644 --- a/api-v3/src/inspection-reports/inspection-report-word-builder.ts +++ b/api-v3/src/inspection-reports/inspection-report-word-builder.ts @@ -11,6 +11,8 @@ interface ReportWordInput { generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; + executiveSummary?: string | null; + reportDescription?: string | null; } const crcTable = (() => { @@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string { } function isoDate(value: unknown): string { - const date = new Date(String(value ?? '')); - return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—'; + const parsed = new Date(String(value ?? '')); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—'; } function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string { @@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string { return `${header}${body}`; } +function f4Snapshot(input: ReportWordInput) { + const source = asRecord(input.frozenSnapshot.source); + const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure); + const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot); + const act = asRecord(locked.act); + const inspection = asRecord(act.inspection ?? act.visit); + const responsible = asRecord(locked.responsible); + const inventories = asArray(locked.inventories ?? locked.assets); + const findings = asArray(locked.findings); + const signatures = asArray(sealedAct.signatures); + return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures }; +} + +function urgency(value: unknown): string { + return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente'; +} + +function deadline(act: Record): string { + const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles'; + if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`; + if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`; + return '—'; +} + function documentXml(input: ReportWordInput): string { - const report = asRecord(input.frozenSnapshot.report); - const actClosure = asRecord(input.frozenSnapshot.actClosure); - const prepared = asRecord(actClosure.preparedSnapshot); - const act = asRecord(prepared.act); - const visit = asRecord(act.visit); - const responsible = asRecord(prepared.responsible); - const team = asArray(prepared.team); - const assets = asArray(prepared.assets); - const findings = asArray(prepared.findings); - const signatures = asArray(actClosure.signatures); - const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))]; - const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))]; - const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean); - const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]); - const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]); - const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]); + const snapshot = f4Snapshot(input); + const findingRows = snapshot.findings.map((item) => [ + text(item.code), + text(item.title), + text(item.description), + item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No', + item.severity == null ? '—' : `${text(item.severity)}/10`, + ]); + const inventoryRows = snapshot.inventories.map((item) => [ + text(item.code), + text(item.name), + text(item.typeName ?? item.typeCode), + ]); + const signatureRows = snapshot.signatures.map((item) => [ + text(item.signerType), + text(item.signerName), + text(item.status), + text(item.companyManifestation, ''), + isoDate(item.signedAt ?? item.createdAt), + ]); + + const executive = input.executiveSummary?.trim() + || '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.'; + const description = input.reportDescription?.trim() + || '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.'; + const body = [ paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'), - paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'), + paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'), labelValue('Informe', input.code), - labelValue('Título', input.title), + labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)), + labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)), + labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)), + labelValue('Urgencia', urgency(snapshot.act.urgency)), + labelValue('Plazo', deadline(snapshot.act)), labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')), - labelValue('Acta', text(report.actCode ?? act.code)), - labelValue('Inspección', text(report.visitCode ?? visit.code)), - labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'), - labelValue('Área', areas.join(' · ') || 'Según alcance del acta'), - labelValue('Inspector/es', inspectorNames.join(' · ') || '—'), - labelValue('Responsable de empresa', text(responsible.fullName)), - labelValue('Cargo', text(responsible.position)), - paragraph('Datos de la visita', 'Heading1'), - labelValue('Objetivo', text(visit.objective)), - labelValue('Fecha de inspección', isoDate(act.occurredAt)), - labelValue('Resumen', text(act.summary)), - labelValue('Observaciones', text(act.observations)), - paragraph('Elementos inspeccionados', 'Heading1'), - assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'), + paragraph('Resumen ejecutivo', 'Heading1'), + paragraph(executive), + paragraph('Descripción / análisis técnico', 'Heading1'), + paragraph(description), + paragraph('Acta fuente · contenido inmutable', 'Heading1'), + paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'), + labelValue('Resumen del Acta', text(snapshot.act.summary)), + labelValue('Observaciones del Acta', text(snapshot.act.observations)), + labelValue('Responsable de empresa', text(snapshot.responsible.fullName)), + labelValue('Cargo', text(snapshot.responsible.position)), + paragraph('Inventario inspeccionado', 'Heading1'), + inventoryRows.length + ? table(['Código', 'Nombre', 'Tipo'], inventoryRows) + : paragraph('No se registraron elementos de Inventario en el Acta.'), paragraph('Hallazgos', 'Heading1'), - findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'), - paragraph('Firmas y constancias', 'Heading1'), - signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'), - paragraph('Integridad documental', 'Heading1'), - labelValue('Hash del informe', input.frozenSha256), - labelValue('Hash de cierre del acta', text(report.actClosureSha256)), - paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'), + findingRows.length + ? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows) + : paragraph('El Acta no contiene Hallazgos.'), + paragraph('Firmas y manifestaciones', 'Heading1'), + signatureRows.length + ? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows) + : paragraph('No se registraron firmas en la instantánea sellada.'), + paragraph('Integridad de la fuente', 'Heading1'), + labelValue('Hash de la fuente del INF', input.frozenSha256), + labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)), + labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)), + paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'), ].join(''); + return `${body}`; } @@ -147,6 +193,7 @@ function buildZip(entries: ZipEntry[]): Buffer { local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28); locals.push(local, name, entry.data); + const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); @@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer { centrals.push(central, name); offset += local.length + name.length + entry.data.length; } + const centralData = Buffer.concat(centrals); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); From 0db4567289edd87a69487c6056488f33ad7615d7 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:31:57 -0300 Subject: [PATCH 063/224] F4: include editable INF fields in generated Word --- .../inspection-report-word.service.ts | 124 +++++++++++++----- 1 file changed, 90 insertions(+), 34 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-report-word.service.ts b/api-v3/src/inspection-reports/inspection-report-word.service.ts index 4074c81..be0a9dd 100644 --- a/api-v3/src/inspection-reports/inspection-report-word.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-word.service.ts @@ -12,6 +12,8 @@ interface WordRow { id: string; code: string; title: string; + executiveSummary: string | null; + reportDescription: string | null; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; @@ -29,7 +31,8 @@ export class InspectionReportWordService { private readonly root: string; constructor(private readonly dataSource: DataSource, config: ConfigService) { - const configured = config.get('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word'; + const configured = config.get('INSPECTION_REPORT_WORD_ROOT') + ?? '/app/storage/asset-media/inspection-reports-word'; if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path'); this.root = resolve(configured); if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root'); @@ -38,7 +41,11 @@ export class InspectionReportWordService { async ensure(reportId: string): Promise { const row = await this.load(reportId); if (row.wordStatus === 'READY' && row.wordStoredName) { - try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {} + try { + await this.content(row.id); + await this.ensureInitialRevision(row); + return; + } catch {} } try { const built = buildInspectionReportWord({ @@ -47,6 +54,8 @@ export class InspectionReportWordService { generatedAt: new Date(row.generatedAt), frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot, + executiveSummary: row.executiveSummary, + reportDescription: row.reportDescription, }); await mkdir(this.root, { recursive: true, mode: 0o700 }); const storedName = `${row.id}.docx`; @@ -55,32 +64,41 @@ export class InspectionReportWordService { await writeFile(filePath, built.buffer, { mode: 0o600 }); await this.dataSource.query(` UPDATE inspection_reports - SET word_status = 'READY', - word_original_name = $2, - word_stored_name = $3, - word_mime_type = $4, - word_size_bytes = $5, - word_sha256 = $6, - word_generated_at = CURRENT_TIMESTAMP, - word_error = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET word_status='READY', + word_original_name=$2, + word_stored_name=$3, + word_mime_type=$4, + word_size_bytes=$5, + word_sha256=$6, + word_generated_at=CURRENT_TIMESTAMP, + word_error=NULL, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]); await this.ensureInitialRevision(await this.load(row.id)); } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; await this.dataSource.query(` UPDATE inspection_reports - SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id, message]).catch(() => undefined); } } async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> { const row = await this.load(reportId); - if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) { - throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' }); + if ( + row.wordStatus !== 'READY' + || !row.wordStoredName + || !row.wordOriginalName + || !row.wordSha256 + || !row.wordSizeBytes + ) { + throw new NotFoundException({ + code: 'INSPECTION_REPORT_WORD_NOT_READY', + message: 'El archivo Word del informe todavía no está disponible', + }); } const filePath = resolve(this.root, row.wordStoredName); if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); @@ -89,40 +107,78 @@ export class InspectionReportWordService { const buffer = await readFile(filePath); const sha256 = createHash('sha256').update(buffer).digest('hex'); if (sha256 !== row.wordSha256) throw this.storageError(); - return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME }; + return { + filePath, + originalName: row.wordOriginalName, + mimeType: row.wordMimeType ?? WORD_MIME, + }; } - private async ensureInitialRevision(row: WordRow): Promise { - if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return; + if ( + row.wordStatus !== 'READY' + || !row.wordOriginalName + || !row.wordStoredName + || !row.wordMimeType + || !row.wordSizeBytes + || !row.wordSha256 + ) return; await this.dataSource.query(` INSERT INTO inspection_report_revisions ( - report_id, revision_number, source, original_name, stored_name, mime_type, - size_bytes, sha256, change_summary, created_by, created_at - ) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)) - ON CONFLICT (report_id, revision_number) DO NOTHING - `, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]); + report_id,revision_number,source,original_name,stored_name,mime_type, + size_bytes,sha256,change_summary,created_by,created_at + ) VALUES ( + $1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7, + COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP) + ) + ON CONFLICT (report_id,revision_number) DO NOTHING + `, [ + row.id, + row.wordOriginalName, + row.wordStoredName, + row.wordMimeType, + row.wordSizeBytes, + row.wordSha256, + row.generatedBy, + ]); await this.dataSource.query(` UPDATE inspection_reports - SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP + WHERE id=$1 `, [row.id]); } private async load(reportId: string): Promise { const [row] = await this.dataSource.query(` - SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256", - frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus", - word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName", - word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes", - word_sha256 AS "wordSha256", generated_by AS "generatedBy" - FROM inspection_reports WHERE id = $1 + SELECT id,code,title, + executive_summary AS "executiveSummary", + report_description AS "reportDescription", + generated_at AS "generatedAt", + frozen_sha256 AS "frozenSha256", + frozen_snapshot AS "frozenSnapshot", + word_status AS "wordStatus", + word_original_name AS "wordOriginalName", + word_stored_name AS "wordStoredName", + word_mime_type AS "wordMimeType", + word_size_bytes::integer AS "wordSizeBytes", + word_sha256 AS "wordSha256", + generated_by AS "generatedBy" + FROM inspection_reports + WHERE id=$1 `, [reportId]) as WordRow[]; - if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' }); + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_REPORT_NOT_FOUND', + message: 'Informe de inspección no encontrado', + }); + } return row; } private storageError(): InternalServerErrorException { - return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' }); + return new InternalServerErrorException({ + code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', + message: 'El archivo Word del informe no está disponible o no supera la validación de integridad', + }); } } From eeb35da02c213cf33ec9b30e5e7092aee19dcd1d Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:32:50 -0300 Subject: [PATCH 064/224] F4: generate Act PDF only from SEALED Acts --- .../inspection-act-pdf.service.ts | 118 ++++++++++++++++-- 1 file changed, 105 insertions(+), 13 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index 056731a..22cef80 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder'; @Injectable() export class InspectionActPdfService { - private readonly root:string; - constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); } + private readonly root: string; - async ensure(actId:string):Promise{ - const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record;status:string|null;storedName:string|null}>; - if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'}); - if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} } - await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]); - try{ - const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600}); - await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]); - }catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); } + constructor(private readonly dataSource: DataSource, config: ConfigService) { + const configured = config.get('INSPECTION_ACT_PDF_ROOT') + ?? '/app/storage/asset-media/inspection-acts-pdf'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); + this.root = resolve(configured); + if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); } - async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; } - private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});} + async ensure(actId: string): Promise { + const [row] = await this.dataSource.query(` + SELECT + act.id, + act.code, + act.closure_sha256 AS "closureSha256", + closure.final_snapshot AS "finalSnapshot", + artifact.status, + artifact.stored_name AS "storedName" + FROM inspection_acts act + JOIN inspection_act_closures closure ON closure.act_id=act.id + LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id + WHERE act.id=$1 AND act.status='SEALED' + `, [actId]) as Array<{ + id: string; + code: string; + closureSha256: string; + finalSnapshot: Record; + status: string | null; + storedName: string | null; + }>; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_ACT_NOT_SEALED', + message: 'El PDF sólo puede generarse desde un Acta SELLADA', + }); + } + if (row.status === 'READY' && row.storedName) { + try { + await this.content(actId); + return; + } catch {} + } + await this.dataSource.query(` + INSERT INTO inspection_act_pdf_artifacts(act_id,status) + VALUES($1,'PENDING') + ON CONFLICT (act_id) DO UPDATE SET + status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP + `, [actId]); + try { + const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 }; + const built = buildInspectionActPdf(snapshot); + await mkdir(this.root, { recursive: true, mode: 0o700 }); + const storedName = `${actId}.pdf`; + const originalName = `${row.code}.pdf`; + const filePath = resolve(this.root, storedName); + await writeFile(filePath, built.buffer, { mode: 0o600 }); + await this.dataSource.query(` + UPDATE inspection_act_pdf_artifacts + SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf', + size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL, + updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 + `, [actId, originalName, storedName, built.buffer.length, built.sha256]); + } catch (error) { + const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido'; + await this.dataSource.query(` + UPDATE inspection_act_pdf_artifacts + SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 + `, [actId, message]).catch(() => undefined); + } + } + + async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { + const [row] = await this.dataSource.query(` + SELECT original_name AS "originalName",stored_name AS "storedName", + mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 + FROM inspection_act_pdf_artifacts + WHERE act_id=$1 AND status='READY' + `, [actId]) as Array<{ + originalName: string; + storedName: string; + mimeType: string; + sizeBytes: number; + sha256: string; + }>; + if (!row) { + throw new NotFoundException({ + code: 'INSPECTION_ACT_PDF_NOT_READY', + message: 'El PDF del Acta todavía no está disponible', + }); + } + const filePath = resolve(this.root, row.storedName); + if (!filePath.startsWith(`${this.root}/`)) throw this.storageError(); + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError(); + const buffer = await readFile(filePath); + if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError(); + return { buffer, originalName: row.originalName, mimeType: row.mimeType }; + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ + code: 'INSPECTION_ACT_PDF_STORAGE_ERROR', + message: 'El PDF del Acta no está disponible o no supera la validación de integridad', + }); + } } From ef084dd8990a223e7ed45231bd3e97b045622727 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:33:53 -0300 Subject: [PATCH 065/224] F4 Android: align ViewModel with urgency lock and seal --- .../korexlabs/dhinspeccion/MainViewModel.kt | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 5124554..96a0be7 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -151,11 +151,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } - fun createActForSelectedInventory() { + fun createActForSelectedInventory(urgency: String = "NON_URGENT") { val currentVisit = visit ?: return val asset = selectedFieldAsset?.asset if (currentVisit.status != "IN_PROGRESS") { - error = "La inspección debe estar en curso para crear un Acta." + error = "La Inspección debe estar en curso para crear un Acta." + return + } + if (urgency !in setOf("URGENT", "NON_URGENT")) { + error = "Elegí si el Acta es urgente o no urgente." return } if (asset == null) { @@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } if (acts.any { it.status == "DRAFT" }) { - error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente." + error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." return } launchBusy { - val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code) + val created = actsRepository.create( + currentVisit.id, + asset.id, + currentVisit.code, + urgency, + ) selectedAct = created actClosure = actsRepository.closure(created.id) loadActsInternal(currentVisit.id, selectDraft = false) - notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." + val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" + notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." if (selectedFieldAsset?.capture?.readyForFinding == true) { loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) } @@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val visitId = visit?.id ?: return launchBusy { selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) - notice = "Inventario agregado a la inspección." + notice = "Inventario agregado a la Inspección." inventory = repository.fieldInventory(visitId, null, null).data val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { @@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) { val visitId = visit?.id ?: return if (visit?.status != "IN_PROGRESS") { - error = "La inspección debe estar en curso para dar de alta Inventario." + error = "La Inspección debe estar en curso para dar de alta Inventario." return } if (type.familyRequired && familyId == null) { @@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { absenceReason = reason.trim(), ), ) - notice = "Ausencia del responsable registrada." + notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta." } } fun prepareSelectedAct() { val actId = selectedAct?.id ?: return launchBusy { - actClosure = actsRepository.prepare(actId) + actClosure = actsRepository.lock(actId) refreshSelectedActInternal(actId) - notice = "Acta preparada. Su contenido quedó congelado para las firmas." + notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones." } } fun reopenSelectedAct() { - val actId = selectedAct?.id ?: return - launchBusy { - actClosure = actsRepository.reopen(actId) - refreshSelectedActInternal(actId) - notice = "Acta reabierta. Podés corregirla antes de volver a preparar." - } + error = "Un Acta bloqueada es inmutable y no puede volver a borrador." } fun signSelectedActAsInspector( @@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun recordCompanyOutcome(status: String, reason: String) { val actId = selectedAct?.id ?: return + if (status != "REFUSED") { + error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa." + return + } if (reason.trim().length < 10) { error = "Indicá un motivo de al menos 10 caracteres." return } launchBusy { actClosure = actsRepository.companyOutcome(actId, status, reason) - notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada." + notice = "Negativa a firmar asentada." } } @@ -521,11 +530,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val currentVisit = visit ?: return val actId = selectedAct?.id ?: return launchBusy { - actClosure = actsRepository.closeAct(actId) + actClosure = actsRepository.seal(actId) refreshSelectedActInternal(actId) loadActsInternal(currentVisit.id, selectDraft = false) clearFindingState() - notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección." + notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección." } } @@ -534,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { visit = actsRepository.closeVisit(visitId) loadVisitsInternal() - notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina." + notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina." } } From 73c69c7a626272f9f995cb58e387831e14c9fdae Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:34:45 -0300 Subject: [PATCH 066/224] F4 Android: redesign Act screen for DRAFT LOCKED SEALED --- .../dhinspeccion/ui/MobileActsScreen.kt | 118 ++++++++++-------- 1 file changed, 68 insertions(+), 50 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 6d2a2dc..2f2d3d9 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -60,6 +60,7 @@ fun MobileActsScreen( val context = LocalContext.current val scope = rememberCoroutineScope() + var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } @@ -129,19 +130,20 @@ fun MobileActsScreen( Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Actas de esta inspección", fontWeight = FontWeight.Bold) + Text("Actas de esta Inspección", fontWeight = FontWeight.Bold) if (model.acts.isEmpty()) { Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") } model.acts.forEach { act -> val active = selected?.id == act.id + val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente" OutlinedButton( onClick = { model.selectAct(act.id) }, modifier = Modifier.fillMaxWidth(), ) { Text( (if (active) "✓ " else "") + - "${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", + "${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", ) } } @@ -156,12 +158,31 @@ fun MobileActsScreen( ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Nueva Acta", fontWeight = FontWeight.Bold) - if (model.acts.any { it.status == "READY" }) { + if (model.acts.any { it.status == "LOCKED" }) { Text( - "Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.", + "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.", style = MaterialTheme.typography.bodySmall, ) } + Text("Urgencia del Acta", fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip( + onClick = { newActUrgency = "NON_URGENT" }, + label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") }, + ) + AssistChip( + onClick = { newActUrgency = "URGENT" }, + label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") }, + ) + } + Text( + if (newActUrgency == "URGENT") { + "El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla." + } else { + "El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla." + }, + style = MaterialTheme.typography.bodySmall, + ) val selectedInventory = model.selectedFieldAsset?.asset if (selectedInventory == null) { Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") @@ -171,7 +192,7 @@ fun MobileActsScreen( } else { Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") Button( - onClick = { model.createActForSelectedInventory() }, + onClick = { model.createActForSelectedInventory(newActUrgency) }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), ) { Text("Crear nueva Acta") } @@ -186,9 +207,14 @@ fun MobileActsScreen( Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(actStatusLabel(selected.status)) + Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente") Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") - Text(selected.summary, style = MaterialTheme.typography.bodySmall) - selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } + selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) } + if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { + Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall) + } + selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) } + selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) } } } @@ -234,31 +260,28 @@ fun MobileActsScreen( HorizontalDivider() Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") + Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.") Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } HorizontalDivider() - Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") + Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.") Button( onClick = { model.prepareSelectedAct() }, enabled = !model.busy && closure?.responsible != null, modifier = Modifier.fillMaxWidth(), - ) { Text("Preparar Acta para firmas") } + ) { Text("Finalizar y BLOQUEAR Acta") } } - "READY" -> { + "LOCKED" -> { val signatures = closure?.signatures.orEmpty() val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } + val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" - Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } - if (signatures.isEmpty()) { - OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) { - Text("Volver a borrador") - } - } + Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.") + closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) } HorizontalDivider() Text("Firma del inspector", fontWeight = FontWeight.Bold) @@ -274,29 +297,21 @@ fun MobileActsScreen( } HorizontalDivider() - Text("Recepción de la empresa", fontWeight = FontWeight.Bold) - if (companyOutcome != null) { + Text("Manifestación de la empresa", fontWeight = FontWeight.Bold) + if (companyOutcome != null && companyResolved) { val detail = when (companyOutcome.status) { - "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad" "REFUSED" -> "Negativa a firmar" - "ABSENT" -> "Responsable ausente" else -> companyOutcome.status } Text("✓ $detail", color = MaterialTheme.colorScheme.primary) companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } } else if (closure?.responsible?.attendanceStatus == "ABSENT") { - Text("El responsable fue registrado como ausente.") - Button( - onClick = { - model.recordCompanyOutcome( - "ABSENT", - closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección", - ) - }, - enabled = !model.busy && inspectorSigned, - modifier = Modifier.fillMaxWidth(), - ) { Text("Asentar ausencia en el Acta") } + Text( + "El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.", + style = MaterialTheme.typography.bodyMedium, + ) } else { Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -333,28 +348,28 @@ fun MobileActsScreen( } HorizontalDivider() - if (inspectorSigned && companyOutcome != null) { + if (inspectorSigned && companyResolved) { Button( onClick = { model.closeSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), - ) { Text("Cerrar Acta definitivamente") } - } else if (inspectorSigned) { + ) { Text("SELLAR Acta definitivamente") } + } else { Text( - "La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", + "Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.", style = MaterialTheme.typography.bodySmall, ) } } - "CLOSED" -> { + "SEALED" -> { Card( Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) - Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") + Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold) + Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.") closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) } } } @@ -366,16 +381,19 @@ fun MobileActsScreen( if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { HorizontalDivider() - val drafts = model.acts.count { it.status == "DRAFT" } - Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text( - "No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", - ) + val activeActs = model.acts.filter { it.status != "CANCELLED" } + val pendingActs = activeActs.filter { it.status != "SEALED" } + Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (pendingActs.isEmpty()) { + Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.") + } else { + Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}") + } Button( onClick = { model.closeInspection() }, - enabled = !model.busy && drafts == 0, + enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(), modifier = Modifier.fillMaxWidth(), - ) { Text("Cerrar inspección y salir de la empresa") } + ) { Text("Cerrar Inspección y salir de la empresa") } } } } @@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) { private fun actStatusLabel(status: String): String = when (status) { "DRAFT" -> "Borrador" - "READY" -> "Preparada para firmas" - "CLOSED" -> "Cerrada" + "LOCKED" -> "BLOQUEADA" + "SEALED" -> "SELLADA" "CANCELLED" -> "Cancelada" else -> status } From 161f4a9252ef5e6de838633254f6cf1ddbbb52d0 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:36:15 -0300 Subject: [PATCH 067/224] F4: add secure remote company signature invitations --- ...0000200000-f4-company-signature-invites.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts diff --git a/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts b/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts new file mode 100644 index 0000000..99c2c4e --- /dev/null +++ b/api-v3/src/database/migrations/1790000200000-f4-company-signature-invites.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F4CompanySignatureInvites1790000200000 implements MigrationInterface { + name = 'F4CompanySignatureInvites1790000200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE inspection_act_company_signature_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + act_id uuid NOT NULL, + token_sha256 char(64) NOT NULL UNIQUE, + recipient_email varchar(320) NOT NULL, + recipient_name varchar(200), + recipient_document_type varchar(20), + recipient_document_number varchar(40), + recipient_position varchar(200), + status varchar(20) NOT NULL DEFAULT 'PENDING', + expires_at timestamptz NOT NULL, + sent_at timestamptz, + used_at timestamptz, + revoked_at timestamptz, + created_by uuid NOT NULL, + revoked_by uuid, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id) + REFERENCES inspection_acts(id) ON DELETE CASCADE, + CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by) + REFERENCES users(id) ON DELETE RESTRICT, + CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by) + REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT chk_company_signature_invite_status CHECK ( + status IN ('PENDING','USED','REVOKED','EXPIRED') + ) + ) + `); + await queryRunner.query(` + CREATE INDEX idx_company_signature_invites_act_created + ON inspection_act_company_signature_invites(act_id,created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX idx_company_signature_invites_pending_expiry + ON inspection_act_company_signature_invites(status,expires_at) + WHERE status='PENDING' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites'); + } +} From badb79be5b041f2647bc0f227b7e2606db1a41a4 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:37:13 -0300 Subject: [PATCH 068/224] F4: add remote company signature invitation DTOs --- .../dto/company-signature-invite.dto.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts diff --git a/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts b/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts new file mode 100644 index 0000000..110b4e1 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/company-signature-invite.dto.ts @@ -0,0 +1,101 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +export class CreateCompanySignatureInviteDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined) + @IsEmail() + @MaxLength(320) + recipientEmail?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + expiresInDays?: number; +} + +export class PublicCompanySignatureDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + fullName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER']) + documentType!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(3) + @MaxLength(40) + documentNumber!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + position!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['CONFORMITY', 'DISSENT']) + manifestation!: 'CONFORMITY' | 'DISSENT'; + + @ValidateIf((value: PublicCompanySignatureDto) => value.manifestation === 'DISSENT') + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(8000) + statement?: string; + + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + consentAccepted!: boolean; +} + +export class PublicCompanyRefusalDto { + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + fullName!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value) + @IsString() + @IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER']) + documentType!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(3) + @MaxLength(40) + documentNumber!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(2) + @MaxLength(200) + position!: string; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(10) + @MaxLength(8000) + reason!: string; +} From a178f64763bde81e1496d303bdad147545c3855b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:38:23 -0300 Subject: [PATCH 069/224] F4: implement secure remote company signature workflow --- .../company-signature-invite.service.ts | 624 ++++++++++++++++++ 1 file changed, 624 insertions(+) create mode 100644 api-v3/src/inspection-closing/company-signature-invite.service.ts diff --git a/api-v3/src/inspection-closing/company-signature-invite.service.ts b/api-v3/src/inspection-closing/company-signature-invite.service.ts new file mode 100644 index 0000000..16bc4ab --- /dev/null +++ b/api-v3/src/inspection-closing/company-signature-invite.service.ts @@ -0,0 +1,624 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; +import { isAbsolute, parse, resolve } from 'node:path'; +import { + ConflictException, + GoneException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AuditSource, + InspectionActSignatureSource, + InspectionActSignatureStatus, + InspectionActSignerType, + InspectionCompanySignatureManifestation, +} from '../database/entities'; +import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service'; +import { sha256CanonicalJson } from './canonical-json'; +import type { + CreateCompanySignatureInviteDto, + PublicCompanyRefusalDto, + PublicCompanySignatureDto, +} from './dto/company-signature-invite.dto'; +import { + inspectInspectionSignatureFile, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +const REMOTE_CONSENT_VERSION = 'F4-1'; +const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.'; + +interface InviteContext { + id: string; + actId: string; + tokenSha256: string; + recipientEmail: string; + recipientName: string | null; + recipientDocumentType: string | null; + recipientDocumentNumber: string | null; + recipientPosition: string | null; + status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED'; + expiresAt: Date; + sentAt: Date | null; + usedAt: Date | null; + createdBy: string; + actCode: string; + actStatus: string; + lockedSha256: string | null; + lockedAt: Date | null; + inspectionCode: string; + lockedSnapshot: Record | null; +} + +@Injectable() +export class CompanySignatureInviteService { + private readonly signatureRoot: string; + + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly smtp: SmtpDeliveryService, + private readonly config: ConfigService, + ) { + const configured = config.get('INSPECTION_SIGNATURE_ROOT') + ?? '/app/storage/asset-media/inspection-signatures'; + if (!isAbsolute(configured)) throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path'); + this.signatureRoot = resolve(configured); + if (this.signatureRoot === parse(this.signatureRoot).root) { + throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root'); + } + } + + async create( + actId: string, + dto: CreateCompanySignatureInviteDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + const token = randomBytes(32).toString('base64url'); + const tokenSha256 = this.hashToken(token); + const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000); + + const created = await this.dataSource.transaction(async (manager) => { + await this.assertActorAssigned(manager, actId, principal); + const [context] = await manager.query(` + SELECT + act.id, + act.code, + act.status, + act.locked_sha256 AS "lockedSha256", + responsible.full_name AS "fullName", + responsible.document_type AS "documentType", + responsible.document_number AS "documentNumber", + responsible.position, + responsible.email AS "responsibleEmail", + visit.code AS "inspectionCode", + ( + SELECT profile.notification_email + FROM inspection_act_assets link + JOIN assets inventory ON inventory.id=link.asset_id + JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id + JOIN assets company ON company.id=COALESCE( + inventory.operator_company_id, + CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END + ) + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + WHERE link.act_id=act.id AND link.included=true + AND profile.notification_email IS NOT NULL + ORDER BY company.id + LIMIT 1 + ) AS "companyEmail" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.visit_id + LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id + WHERE act.id=$1 + FOR UPDATE OF act + `, [actId]) as Array<{ + id: string; + code: string; + status: string; + lockedSha256: string | null; + fullName: string | null; + documentType: string | null; + documentNumber: string | null; + position: string | null; + responsibleEmail: string | null; + inspectionCode: string; + companyEmail: string | null; + }>; + if (!context) { + throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' }); + } + if (context.status !== 'LOCKED' || !context.lockedSha256) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED', + message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA', + }); + } + if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', + message: 'La manifestación de la empresa ya fue registrada', + }); + } + const recipientEmail = dto.recipientEmail + ?? context.responsibleEmail?.toLowerCase() + ?? context.companyEmail?.toLowerCase() + ?? null; + if (!recipientEmail) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED', + message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario', + }); + } + + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 AND status='PENDING' + `, [actId, principal.userId]); + + const [invite] = await manager.query(` + INSERT INTO inspection_act_company_signature_invites( + act_id,token_sha256,recipient_email,recipient_name,recipient_document_type, + recipient_document_number,recipient_position,expires_at,created_by + ) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail", + expires_at AS "expiresAt",created_at AS "createdAt" + `, [ + actId, + tokenSha256, + recipientEmail, + context.fullName, + context.documentType, + context.documentNumber, + context.position, + expiresAt, + principal.userId, + ]) as Array<{ + id: string; + actId: string; + recipientEmail: string; + expiresAt: Date; + createdAt: Date; + }>; + + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED', + entityType: 'inspection_act_company_signature_invite', + entityId: invite.id, + afterData: { + actId, + actCode: context.code, + recipientEmail, + expiresAt, + }, + metadata: { tokenStoredAsHashOnly: true }, + }, manager); + return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode }; + }); + + const publicBase = this.config.get('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null; + const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null; + let emailSent = false; + let deliveryError: string | null = null; + + if (!publicUrl) { + deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada'; + } else if (!(await this.smtp.configured())) { + deliveryError = 'SMTP no configurado'; + } else { + const body = [ + `Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`, + `Inspección: ${created.inspectionCode}.`, + '', + 'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.', + 'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.', + '', + `Enlace seguro: ${publicUrl}`, + `Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`, + ].join('\n'); + try { + await this.smtp.send({ + to: created.recipientEmail, + subject: `DH Inspección · Firma de ${created.actCode}`, + text: body, + attachment: { + filename: `${created.actCode}-instrucciones.txt`, + mimeType: 'text/plain', + content: Buffer.from(body, 'utf8'), + }, + }); + emailSent = true; + await this.dataSource.query(` + UPDATE inspection_act_company_signature_invites + SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP + WHERE id=$1 + `, [created.id]); + } catch (error) { + deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email'; + } + } + + return { + id: created.id, + actId: created.actId, + actCode: created.actCode, + recipientEmail: created.recipientEmail, + expiresAt: created.expiresAt, + emailSent, + deliveryError, + publicUrl, + }; + } + + async view(token: string) { + const invite = await this.resolveToken(this.dataSource.manager, token, false); + const locked = invite.lockedSnapshot ?? {}; + const act = this.record(locked.act); + const inventories = this.records(locked.inventories); + const findings = this.records(locked.findings).map((finding) => ({ + id: finding.id, + code: finding.code, + title: finding.title, + description: finding.description, + legalBasis: finding.legalBasis ?? null, + severity: finding.severity ?? null, + isRecurrence: finding.isRecurrence === true, + recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null, + })); + return { + invitation: { + id: invite.id, + recipientEmail: invite.recipientEmail, + expiresAt: invite.expiresAt, + }, + act: { + code: invite.actCode, + inspectionCode: invite.inspectionCode, + lockedAt: invite.lockedAt, + lockedSha256: invite.lockedSha256, + urgency: act.urgency ?? null, + summary: act.summary ?? null, + observations: act.observations ?? null, + }, + responsibleDefaults: { + fullName: invite.recipientName, + documentType: invite.recipientDocumentType, + documentNumber: invite.recipientDocumentNumber, + position: invite.recipientPosition, + }, + inventories: inventories.map((inventory) => ({ + id: inventory.id, + code: inventory.code, + name: inventory.name, + typeName: inventory.typeName ?? inventory.typeCode ?? null, + })), + findings, + consent: REMOTE_COMPANY_CONSENT, + allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'], + }; + } + + async sign( + token: string, + dto: PublicCompanySignatureDto, + file: UploadedInspectionSignatureFile | undefined, + request: RequestWithContext, + ) { + if (!dto.consentAccepted) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED', + message: 'Debe aceptarse la constancia antes de firmar', + }); + } + const inspected = inspectInspectionSignatureFile(file); + const id = randomUUID(); + const storedName = `${id}.png`; + const filePath = resolve(this.signatureRoot, storedName); + const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex'); + await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 }); + await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 }); + + try { + const result = await this.dataSource.transaction(async (manager) => { + const invite = await this.resolveToken(manager, token, true); + await this.assertNoCompanyOutcome(manager, invite.actId); + const signedAt = new Date(); + const manifestation = dto.manifestation === 'DISSENT' + ? InspectionCompanySignatureManifestation.DISSENT + : InspectionCompanySignatureManifestation.CONFORMITY; + const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT + ? dto.statement?.trim() ?? null + : null; + const payload = { + invitationId: invite.id, + actId: invite.actId, + lockedSha256: invite.lockedSha256, + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName: dto.fullName, + documentType: dto.documentType, + documentNumber: dto.documentNumber, + position: dto.position, + status: InspectionActSignatureStatus.SIGNED, + companyManifestation: manifestation, + companyStatement: statement, + imageSha256, + consentText: REMOTE_COMPANY_CONSENT, + consentVersion: REMOTE_CONSENT_VERSION, + signedAt: signedAt.toISOString(), + source: InspectionActSignatureSource.WEB, + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + await manager.query(` + 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, + signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES( + $1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13, + $14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16 + ) + `, [ + id, + invite.actId, + dto.fullName, + dto.documentType, + dto.documentNumber, + dto.position, + manifestation, + statement, + inspected.originalName, + storedName, + inspected.mimeType, + file!.buffer.length, + imageSha256, + REMOTE_COMPANY_CONSENT, + REMOTE_CONSENT_VERSION, + signedAt, + invite.lockedSha256, + signaturePayloadSha256, + invite.createdBy, + ]); + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='USED',used_at=$2,updated_at=$2 + WHERE id=$1 + `, [invite.id, signedAt]); + await this.audit.record({ + action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED', + entityType: 'inspection_act_signature', + entityId: id, + source: AuditSource.WEB, + actorUserId: null, + actorUsername: null, + requestId: request.requestId, + ip: request.ip ?? null, + userAgent: request.get('user-agent') ?? null, + afterData: payload, + metadata: { + invitationId: invite.id, + recipientEmail: invite.recipientEmail, + immutable: true, + signaturePayloadSha256, + }, + }, manager); + return { actCode: invite.actCode, signatureId: id, manifestation }; + }); + return { ok: true, ...result }; + } catch (error) { + await unlink(filePath).catch(() => undefined); + throw error; + } + } + + async refuse( + token: string, + dto: PublicCompanyRefusalDto, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const invite = await this.resolveToken(manager, token, true); + await this.assertNoCompanyOutcome(manager, invite.actId); + const createdAt = new Date(); + const id = randomUUID(); + const payload = { + invitationId: invite.id, + actId: invite.actId, + lockedSha256: invite.lockedSha256, + signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, + signerName: dto.fullName, + documentType: dto.documentType, + documentNumber: dto.documentNumber, + position: dto.position, + status: InspectionActSignatureStatus.REFUSED, + reason: dto.reason, + source: InspectionActSignatureSource.WEB, + createdAt: createdAt.toISOString(), + }; + const signaturePayloadSha256 = sha256CanonicalJson(payload); + await manager.query(` + INSERT INTO inspection_act_signatures( + id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number, + position,status,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11) + `, [ + id, + invite.actId, + dto.fullName, + dto.documentType, + dto.documentNumber, + dto.position, + dto.reason, + invite.lockedSha256, + signaturePayloadSha256, + invite.createdBy, + createdAt, + ]); + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='USED',used_at=$2,updated_at=$2 + WHERE id=$1 + `, [invite.id, createdAt]); + await this.audit.record({ + action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED', + entityType: 'inspection_act_signature', + entityId: id, + source: AuditSource.WEB, + actorUserId: null, + actorUsername: null, + requestId: request.requestId, + ip: request.ip ?? null, + userAgent: request.get('user-agent') ?? null, + afterData: payload, + metadata: { + invitationId: invite.id, + recipientEmail: invite.recipientEmail, + immutable: true, + signaturePayloadSha256, + }, + }, manager); + return { ok: true, actCode: invite.actCode, refusalId: id }; + }); + } + + private async resolveToken( + manager: EntityManager, + token: string, + lock: boolean, + ): Promise { + if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite(); + const tokenSha256 = this.hashToken(token); + const [invite] = await manager.query(` + SELECT + invite.id, + invite.act_id AS "actId", + invite.token_sha256 AS "tokenSha256", + invite.recipient_email AS "recipientEmail", + invite.recipient_name AS "recipientName", + invite.recipient_document_type AS "recipientDocumentType", + invite.recipient_document_number AS "recipientDocumentNumber", + invite.recipient_position AS "recipientPosition", + invite.status, + invite.expires_at AS "expiresAt", + invite.sent_at AS "sentAt", + invite.used_at AS "usedAt", + invite.created_by AS "createdBy", + act.code AS "actCode", + act.status AS "actStatus", + act.locked_sha256 AS "lockedSha256", + act.locked_at AS "lockedAt", + visit.code AS "inspectionCode", + closure.prepared_snapshot AS "lockedSnapshot" + FROM inspection_act_company_signature_invites invite + JOIN inspection_acts act ON act.id=invite.act_id + JOIN inspection_visits visit ON visit.id=act.visit_id + JOIN inspection_act_closures closure ON closure.act_id=act.id + WHERE invite.token_sha256=$1 + ${lock ? 'FOR UPDATE OF invite,act' : ''} + `, [tokenSha256]) as InviteContext[]; + if (!invite) throw this.invalidInvite(); + if (invite.status !== 'PENDING') { + throw new GoneException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE', + message: invite.status === 'USED' + ? 'Este enlace ya fue utilizado' + : 'Este enlace ya no está vigente', + }); + } + if (new Date(invite.expiresAt).getTime() <= Date.now()) { + await manager.query(` + UPDATE inspection_act_company_signature_invites + SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP + WHERE id=$1 AND status='PENDING' + `, [invite.id]); + throw new GoneException({ + code: 'COMPANY_SIGNATURE_INVITE_EXPIRED', + message: 'El enlace de firma venció. Solicitá una nueva invitación.', + }); + } + if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE', + message: 'El Acta ya no está disponible para manifestación remota', + }); + } + return invite; + } + + private async assertActorAssigned( + manager: EntityManager, + actId: string, + principal: AuthPrincipal, + ): Promise { + if (principal.permissions.includes('inspections.manage')) return; + const [row] = await manager.query(` + SELECT 1 + FROM inspection_acts act + JOIN inspection_visit_members member ON member.visit_id=act.visit_id + WHERE act.id=$1 AND member.user_id=$2 AND member.included=true + LIMIT 1 + `, [actId, principal.userId]) as unknown[]; + if (!row) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED', + message: 'Sólo un Inspector asignado puede emitir la invitación', + }); + } + } + + private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise { + if (await this.hasCompanyOutcome(manager, actId)) { + throw new ConflictException({ + code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED', + message: 'La manifestación de la empresa ya fue registrada', + }); + } + } + + private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { + const rows = await manager.query(` + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE' + LIMIT 1 + `, [actId]) as unknown[]; + return rows.length > 0; + } + + private hashToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } + + private invalidInvite(): NotFoundException { + return new NotFoundException({ + code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND', + message: 'El enlace de firma no es válido', + }); + } + + private record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + } + + private records(value: unknown): Array> { + return Array.isArray(value) ? value.map((item) => this.record(item)) : []; + } + + private storageError(): InternalServerErrorException { + return new InternalServerErrorException({ + code: 'COMPANY_SIGNATURE_STORAGE_ERROR', + message: 'No se pudo almacenar la firma remota', + }); + } +} From 343dbe086f889538c741ccdd573d9dd9ff5c12fd Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:38:40 -0300 Subject: [PATCH 070/224] F4: expose secure remote company signature endpoints --- .../company-signature-invite.controller.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 api-v3/src/inspection-closing/company-signature-invite.controller.ts diff --git a/api-v3/src/inspection-closing/company-signature-invite.controller.ts b/api-v3/src/inspection-closing/company-signature-invite.controller.ts new file mode 100644 index 0000000..d1a4b93 --- /dev/null +++ b/api-v3/src/inspection-closing/company-signature-invite.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { SkipCsrf } from '../auth/decorators/skip-csrf.decorator'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CompanySignatureInviteService } from './company-signature-invite.service'; +import { + CreateCompanySignatureInviteDto, + PublicCompanyRefusalDto, + PublicCompanySignatureDto, +} from './dto/company-signature-invite.dto'; +import { + MAX_INSPECTION_SIGNATURE_BYTES, + type UploadedInspectionSignatureFile, +} from './inspection-signature-file'; + +@Controller('inspection-acts/:actId/company-signature-invitations') +export class CompanySignatureInviteController { + constructor(private readonly invites: CompanySignatureInviteService) {} + + @Post() + @RequirePermissions('inspection_closure.sign') + create( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Body() dto: CreateCompanySignatureInviteDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.invites.create(actId, dto, principal, request); + } +} + +@Public() +@SkipCsrf() +@Controller('public/company-signatures') +export class PublicCompanySignatureController { + constructor(private readonly invites: CompanySignatureInviteService) {} + + @Get(':token') + view(@Param('token') token: string) { + return this.invites.view(token); + } + + @Post(':token/sign') + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 }, + })) + sign( + @Param('token') token: string, + @Body() dto: PublicCompanySignatureDto, + @UploadedFile() file: UploadedInspectionSignatureFile | undefined, + @Req() request: RequestWithContext, + ) { + return this.invites.sign(token, dto, file, request); + } + + @Post(':token/refuse') + refuse( + @Param('token') token: string, + @Body() dto: PublicCompanyRefusalDto, + @Req() request: RequestWithContext, + ) { + return this.invites.refuse(token, dto, request); + } +} From e577e4c97664a5188013678686ff033102053a4a Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:38:56 -0300 Subject: [PATCH 071/224] F4: export SMTP delivery for remote signature invitations --- api-v3/src/inspection-reports/inspection-reports.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index 6f5d076..4696531 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -28,6 +28,6 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; InspectionDocumentDeliveryService, SmtpDeliveryService, ], - exports: [InspectionReportsService], + exports: [InspectionReportsService, SmtpDeliveryService], }) export class InspectionReportsModule {} From bdcf193a9058c9b9a6a4867fdebf8fcb6f633165 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:39:05 -0300 Subject: [PATCH 072/224] F4: wire remote company signature workflow --- .../inspection-closing.module.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-closing/inspection-closing.module.ts b/api-v3/src/inspection-closing/inspection-closing.module.ts index 4ea68b5..8a264e6 100644 --- a/api-v3/src/inspection-closing/inspection-closing.module.ts +++ b/api-v3/src/inspection-closing/inspection-closing.module.ts @@ -1,6 +1,11 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module'; +import { + CompanySignatureInviteController, + PublicCompanySignatureController, +} from './company-signature-invite.controller'; +import { CompanySignatureInviteService } from './company-signature-invite.service'; import { InspectionClosingController, InspectionSignatureContentController, @@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service'; @Module({ imports: [AuditModule, InspectionReportsModule], - controllers: [InspectionClosingController, InspectionSignatureContentController], - providers: [InspectionClosingService], + controllers: [ + InspectionClosingController, + InspectionSignatureContentController, + CompanySignatureInviteController, + PublicCompanySignatureController, + ], + providers: [InspectionClosingService, CompanySignatureInviteService], }) export class InspectionClosingModule {} From f4e5480ac8459fe2ecef03ceed86a456049e3268 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:42:26 -0300 Subject: [PATCH 073/224] F4 WEB: add public secure Act signature page --- web-v2/src/pages/CompanySignaturePage.tsx | 414 ++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 web-v2/src/pages/CompanySignaturePage.tsx diff --git a/web-v2/src/pages/CompanySignaturePage.tsx b/web-v2/src/pages/CompanySignaturePage.tsx new file mode 100644 index 0000000..edbb826 --- /dev/null +++ b/web-v2/src/pages/CompanySignaturePage.tsx @@ -0,0 +1,414 @@ +import { useEffect, useRef, useState } from 'react'; +import type { FormEvent, PointerEvent as ReactPointerEvent } from 'react'; +import { useSearchParams } from 'react-router'; + +interface PublicFinding { + id: string; + code: string; + title: string; + description: string; + legalBasis: string | null; + severity: number | null; + isRecurrence: boolean; + recurrenceOfFindingId: string | null; +} + +interface PublicInventory { + id: string; + code: string; + name: string; + typeName: string | null; +} + +interface PublicSignatureView { + invitation: { + id: string; + recipientEmail: string; + expiresAt: string; + }; + act: { + code: string; + inspectionCode: string; + lockedAt: string; + lockedSha256: string; + urgency: string | null; + summary: string | null; + observations: string | null; + }; + responsibleDefaults: { + fullName: string | null; + documentType: string | null; + documentNumber: string | null; + position: string | null; + }; + inventories: PublicInventory[]; + findings: PublicFinding[]; + consent: string; + allowedActions: string[]; +} + +interface ApiProblem { + message?: string; + code?: string; +} + +const API_BASE = '/api/v3/public/company-signatures'; + +const shellStyle: React.CSSProperties = { + minHeight: '100vh', + background: '#f3f6f8', + padding: '32px 16px 56px', + color: '#17313f', +}; + +const cardStyle: React.CSSProperties = { + maxWidth: 900, + margin: '0 auto 16px', + background: '#fff', + border: '1px solid #d8e1e7', + borderRadius: 16, + padding: 24, + boxShadow: '0 8px 30px rgba(23,49,63,.06)', +}; + +const gridStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', + gap: 12, +}; + +const fieldStyle: React.CSSProperties = { + width: '100%', + border: '1px solid #b8c7d0', + borderRadius: 9, + padding: '11px 12px', + font: 'inherit', + boxSizing: 'border-box', +}; + +const primaryButtonStyle: React.CSSProperties = { + border: 0, + borderRadius: 10, + padding: '12px 16px', + fontWeight: 700, + cursor: 'pointer', + background: '#0d5f78', + color: '#fff', +}; + +const secondaryButtonStyle: React.CSSProperties = { + ...primaryButtonStyle, + border: '1px solid #9fb3be', + background: '#fff', + color: '#17313f', +}; + +async function problem(response: Response): Promise { + const body = await response.json().catch(() => ({} as ApiProblem)) as ApiProblem; + if (body.message) return body.message; + if (response.status === 410) return 'Este enlace ya fue utilizado o venció.'; + if (response.status === 404) return 'El enlace de firma no es válido.'; + return `No se pudo completar la operación (HTTP ${response.status}).`; +} + +function SignaturePad({ disabled, onClearReady }: { disabled: boolean; onClearReady?: (clear: () => void) => void }) { + const canvasRef = useRef(null); + const drawing = useRef(false); + const [hasInk, setHasInk] = useState(false); + + const clear = () => { + const canvas = canvasRef.current; + if (!canvas) return; + canvas.getContext('2d')?.clearRect(0, 0, canvas.width, canvas.height); + setHasInk(false); + }; + + useEffect(() => { + onClearReady?.(clear); + }, [onClearReady]); + + const point = (event: ReactPointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return null; + const rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) * (canvas.width / rect.width), + y: (event.clientY - rect.top) * (canvas.height / rect.height), + }; + }; + + const start = (event: ReactPointerEvent) => { + if (disabled) return; + const canvas = canvasRef.current; + const p = point(event); + if (!canvas || !p) return; + canvas.setPointerCapture(event.pointerId); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.strokeStyle = '#102a36'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + drawing.current = true; + }; + + const move = (event: ReactPointerEvent) => { + if (!drawing.current || disabled) return; + const p = point(event); + const ctx = canvasRef.current?.getContext('2d'); + if (!p || !ctx) return; + ctx.lineTo(p.x, p.y); + ctx.stroke(); + setHasInk(true); + }; + + const stop = () => { + drawing.current = false; + }; + + return ( +
+ +
+ {hasInk ? 'Firma capturada.' : 'Firmá dentro del recuadro.'} + +
+
+ ); +} + +export function CompanySignaturePage() { + const [params] = useSearchParams(); + const token = params.get('token')?.trim() ?? ''; + const [view, setView] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [done, setDone] = useState(''); + const [action, setAction] = useState<'CONFORMITY' | 'DISSENT' | 'REFUSE'>('CONFORMITY'); + const [fullName, setFullName] = useState(''); + const [documentType, setDocumentType] = useState('DNI'); + const [documentNumber, setDocumentNumber] = useState(''); + const [position, setPosition] = useState(''); + const [statement, setStatement] = useState(''); + const [refusalReason, setRefusalReason] = useState(''); + const [consent, setConsent] = useState(false); + const canvasRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + if (!token) { + setError('El enlace no contiene un token de firma.'); + setLoading(false); + return; + } + try { + const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}`, { + credentials: 'omit', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(await problem(response)); + const data = await response.json() as PublicSignatureView; + if (cancelled) return; + setView(data); + setFullName(data.responsibleDefaults.fullName ?? ''); + setDocumentType(data.responsibleDefaults.documentType ?? 'DNI'); + setDocumentNumber(data.responsibleDefaults.documentNumber ?? ''); + setPosition(data.responsibleDefaults.position ?? ''); + } catch (loadError) { + if (!cancelled) setError(loadError instanceof Error ? loadError.message : 'No se pudo abrir el enlace.'); + } finally { + if (!cancelled) setLoading(false); + } + }; + void load(); + return () => { cancelled = true; }; + }, [token]); + + const identityValid = fullName.trim().length >= 2 + && documentNumber.trim().length >= 3 + && position.trim().length >= 2; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!view || !token || !identityValid) return; + setError(''); + setSubmitting(true); + try { + if (action === 'REFUSE') { + if (refusalReason.trim().length < 10) { + throw new Error('Explicá el motivo de la negativa con al menos 10 caracteres.'); + } + const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}/refuse`, { + method: 'POST', + credentials: 'omit', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + fullName: fullName.trim(), + documentType, + documentNumber: documentNumber.trim(), + position: position.trim(), + reason: refusalReason.trim(), + }), + }); + if (!response.ok) throw new Error(await problem(response)); + setDone(`Negativa registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.`); + return; + } + + if (!consent) throw new Error('Debés aceptar la constancia antes de firmar.'); + if (action === 'DISSENT' && statement.trim().length < 10) { + throw new Error('Escribí la manifestación de disidencia con al menos 10 caracteres.'); + } + const canvas = document.querySelector('canvas[aria-label="Área para firma manuscrita"]') as HTMLCanvasElement | null; + canvasRef.current = canvas; + if (!canvas) throw new Error('No se encontró el área de firma.'); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('No se pudo capturar la firma.'); + const imageData = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height).data; + const hasInk = imageData ? Array.from(imageData).some((value, index) => index % 4 === 3 && value > 0) : false; + if (!hasInk) throw new Error('Firmá dentro del recuadro antes de continuar.'); + + const form = new FormData(); + form.append('fullName', fullName.trim()); + form.append('documentType', documentType); + form.append('documentNumber', documentNumber.trim()); + form.append('position', position.trim()); + form.append('manifestation', action); + if (action === 'DISSENT') form.append('statement', statement.trim()); + form.append('consentAccepted', 'true'); + form.append('file', blob, 'firma.png'); + const response = await fetch(`${API_BASE}/${encodeURIComponent(token)}/sign`, { + method: 'POST', + credentials: 'omit', + headers: { Accept: 'application/json' }, + body: form, + }); + if (!response.ok) throw new Error(await problem(response)); + setDone( + action === 'DISSENT' + ? `Firma en disidencia registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.` + : `Firma registrada para ${view.act.code}. Este enlace ya no puede volver a utilizarse.`, + ); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'No se pudo registrar la manifestación.'); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return

DH Inspección

Validando enlace seguro…

; + } + if (done) { + return

Manifestación registrada

{done}

Podés cerrar esta ventana.

; + } + if (!view) { + return

Enlace no disponible

{error || 'No se pudo abrir el Acta.'}

; + } + + return ( +
+
+
+ DH +
Dirección de Hidrocarburos
Manifestación sobre Acta de Inspección
+
+

{view.act.code}

+

Inspección {view.act.inspectionCode}

+
+
Estado
BLOQUEADA
+
Urgencia
{view.act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}
+
Vigencia del enlace
{new Date(view.invitation.expiresAt).toLocaleString('es-AR')}
+
+
+ Hash del contenido bloqueado
{view.act.lockedSha256} +
+
+ +
+

Contenido del Acta

+ {view.act.summary && <>Resumen

{view.act.summary}

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

{view.act.observations}

} +

Inventario inspeccionado

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

Sin Inventario detallado.

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

Hallazgos

+ {view.findings.length === 0 ?

El Acta no contiene Hallazgos.

: view.findings.map((finding) => ( +
+
+ {finding.code} · {finding.title} + {finding.isRecurrence && REINCIDENCIA} +
+

Descripción: {finding.description}

+ {finding.legalBasis &&

Base legal: {finding.legalBasis}

} + {finding.severity != null && Gravedad: {finding.severity}/10} +
+ ))} +
+ +
+

Identificación del responsable

+
+ + + + +
+ +

Manifestación

+
+ + + +
+ + {action === 'DISSENT' &&