feat(f6.8): harden offline field flow and act documents
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m41s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m14s

This commit is contained in:
DH V2
2026-09-15 15:56:50 -03:00
parent 47cd985931
commit 079728aa6d
62 changed files with 2240 additions and 338 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.29.0-7",
"version": "0.29.0-8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-7",
"version": "0.29.0-8",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-7",
"version": "0.29.0-8",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
{
code: 'equipo',
name: 'Equipo',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
@@ -756,6 +756,7 @@ export class AssetsService {
const definitions = await this.loadDefinitions(manager, type.id);
const values = validateAssetAttributeValues(definitions, dto.attributes);
const asset = manager.getRepository(Asset).create({
id: dto.clientGeneratedId,
assetTypeId: type.id,
parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId,
@@ -2,6 +2,10 @@ import { Transform } from 'class-transformer';
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateFieldDiscoveryDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4')
visitId!: string;
@@ -98,7 +98,7 @@ export class InventoryFamilyCatalogService {
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
@@ -186,7 +186,7 @@ export class InventoryFamilyCatalogService {
} catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación',
message:'Ya existe un campo técnico con ese código en el tipo',
});
throw error;
}
@@ -328,7 +328,7 @@ export class InventoryFamilyCatalogService {
${lockClause}
`),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe',
});
return rows[0];
}
@@ -343,7 +343,7 @@ export class InventoryFamilyCatalogService {
if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no lleva compatibilidades padre',
message:'Un tipo de Instalación no lleva compatibilidades padre',
});
return [];
}
@@ -352,7 +352,7 @@ export class InventoryFamilyCatalogService {
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
});
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
});
const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families
@@ -434,7 +434,7 @@ export class InventoryFamilyCatalogService {
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información',
});
return [...unique.values()];
}
@@ -253,7 +253,7 @@ export class InventoryStructureService {
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family
? `Clasificación técnica: ${family.code} · ${family.name}`
? `Tipo: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
@@ -458,13 +458,13 @@ export class InventoryStructureService {
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
});
return null;
}
if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
message: `Elegí el tipo de la ${kind.toLowerCase()}`,
});
const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level,
@@ -479,15 +479,15 @@ export class InventoryStructureService {
LIMIT 1
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La clasificación técnica no corresponde al nivel seleccionado',
message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
});
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
message:'La Instalación padre debe tener un Tipo de instalación válido',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
@@ -496,7 +496,7 @@ export class InventoryStructureService {
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
});
}
return family;
@@ -29,7 +29,7 @@ export class InventoryTechnicalValuesService {
for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual',
});
const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value;
@@ -67,7 +67,7 @@ export class InventoryTechnicalValuesService {
const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro',
});
const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F68OfflineEvidenceIdempotency1790128200000 implements MigrationInterface {
name = 'F68OfflineEvidenceIdempotency1790128200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_finding_evidence
ADD COLUMN client_operation_id uuid
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_finding_evidence_client_operation
ON inspection_finding_evidence (client_operation_id)
WHERE client_operation_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE asset_field_capture_events
ADD COLUMN client_operation_id uuid
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_asset_field_capture_events_client_operation
ON asset_field_capture_events (client_operation_id)
WHERE client_operation_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX uq_asset_field_capture_events_client_operation');
await queryRunner.query('ALTER TABLE asset_field_capture_events DROP COLUMN client_operation_id');
await queryRunner.query('DROP INDEX uq_inspection_finding_evidence_client_operation');
await queryRunner.query('ALTER TABLE inspection_finding_evidence DROP COLUMN client_operation_id');
}
}
@@ -12,6 +12,10 @@ import {
} from 'class-validator';
export class CreateInspectionActDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsISO8601({ strict: true })
occurredAt!: string;
@@ -53,6 +53,12 @@ export class InspectionActsController {
return this.acts.listGlobal(query);
}
@Get(':id/field-media')
@RequirePermissions('inspection_acts.read', 'assets.read_media')
fieldMedia(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.acts.listFieldMedia(id);
}
@Get(':id')
@RequirePermissions('inspection_acts.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -278,6 +278,48 @@ export class InspectionActsService {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async listFieldMedia(id: string) {
const [act] = await this.dataSource.query(
'SELECT id FROM inspection_acts WHERE id=$1::uuid',
[id],
) as Array<{ id: string }>;
if (!act) throw actNotFound();
const data = await this.dataSource.query(`
SELECT
media.id,
media.asset_id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
media.kind,
media.original_name AS "originalName",
media.mime_type AS "mimeType",
media.size_bytes::double precision AS "sizeBytes",
media.sha256,
media.title,
media.description,
media.captured_at AS "capturedAt",
media.latitude::double precision AS latitude,
media.longitude::double precision AS longitude,
media.accuracy_m::double precision AS "accuracyM",
media.source,
capture.device_captured_at AS "fieldCapturedAt",
capture.created_at AS "createdAt"
FROM inspection_acts act
INNER JOIN inspection_act_assets link
ON link.act_id=act.id AND link.included=true
INNER JOIN asset_field_capture_events capture
ON capture.visit_id=act.visit_id
AND capture.asset_id=link.asset_id
AND capture.event_type='PHOTO'
INNER JOIN asset_media media
ON media.id=capture.media_id AND media.deleted_at IS NULL
INNER JOIN assets asset ON asset.id=media.asset_id
WHERE act.id=$1::uuid
ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id
`, [id]);
return { data };
}
async create(
visitId: string,
dto: CreateInspectionActDto,
@@ -289,6 +331,22 @@ export class InspectionActsService {
const visit = await this.lockVisit(manager, visitId);
this.assertVisitOpen(visit);
await this.assertActorAssigned(manager, visitId, principal);
if (dto.clientGeneratedId) {
const [existing] = await manager.query(`
SELECT id, visit_id AS "visitId"
FROM inspection_acts
WHERE id=$1
`, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>;
if (existing) {
if (existing.visitId !== visitId) {
throw new ConflictException({
code: 'INSPECTION_ACT_CLIENT_ID_REUSED',
message: 'El identificador offline del Acta ya fue utilizado en otra inspección',
});
}
return this.loadView(manager, existing.id);
}
}
await this.assertVisitHasNoDraftAct(manager, visitId);
await this.assertVisitAssets(manager, visitId, dto.assetIds);
const occurredAt = new Date(dto.occurredAt);
@@ -305,6 +363,7 @@ export class InspectionActsService {
`, [occurredAt])) as Array<{ date_part: string }>;
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
const act = manager.getRepository(InspectionAct).create({
id: dto.clientGeneratedId,
visitId,
actYear,
actNumber,
@@ -16,6 +16,10 @@ import {
} from '../../database/entities';
export class CreateInspectionEvidenceDto {
@IsOptional()
@IsUUID('4')
operationId?: string;
@IsEnum(InspectionEvidenceKind)
kind!: InspectionEvidenceKind;
@@ -16,6 +16,10 @@ const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateInspectionFindingDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4')
assetId!: string;
@@ -61,7 +61,7 @@ export class F3FindingCatalogResolverService {
inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false,
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.',
categories: [],
items: [],
other,
@@ -120,7 +120,7 @@ export class F3FindingCatalogResolverService {
},
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories,
items,
other,
@@ -153,6 +153,22 @@ export class InspectionEvidenceService {
assertMobileInspector(principal);
}
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
if (dto.operationId) {
const [existing] = await this.dataSource.query(
`${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`,
[dto.operationId],
) as StoredInspectionEvidence[];
if (existing) {
if (existing.findingId !== findingId) {
throw new ConflictException({
code: 'INSPECTION_EVIDENCE_OPERATION_REUSED',
message: 'La operación offline ya fue utilizada para otra evidencia',
});
}
const { storedName: _storedName, ...view } = existing;
return view;
}
}
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
this.validatePurpose(dto, inspected.mimeType);
@@ -210,12 +226,12 @@ export class InspectionEvidenceService {
id, finding_id, communication_id, verification_visit_id, kind, purpose,
original_name, stored_name, mime_type, size_bytes, sha256,
title, description, captured_at, latitude, longitude, accuracy_m,
device_label, source, uploaded_by
device_label, source, uploaded_by, client_operation_id
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18, $19, $20
$18, $19, $20, $21
)
`, [
id,
@@ -238,6 +254,7 @@ export class InspectionEvidenceService {
dto.deviceLabel?.trim() || null,
source,
principal.userId,
dto.operationId ?? null,
]);
const created = await this.loadEvidence(manager, id);
await this.audit.record({
@@ -398,6 +398,22 @@ export class InspectionFindingsService {
const visit = await this.lockVisit(manager, act.visitId);
this.assertActEditable(act, visit);
await this.assertActorAssigned(manager, visit.id, principal);
if (dto.clientGeneratedId) {
const [existing] = await manager.query(`
SELECT id, act_id AS "actId", asset_id AS "assetId"
FROM inspection_findings
WHERE id=$1
`, [dto.clientGeneratedId]) as Array<{ id: string; actId: string; assetId: string }>;
if (existing) {
if (existing.actId !== actId || existing.assetId !== dto.assetId) {
throw new ConflictException({
code: 'INSPECTION_FINDING_CLIENT_ID_REUSED',
message: 'El identificador offline del Hallazgo ya fue utilizado en otro contexto',
});
}
return this.loadView(manager, existing.id);
}
}
await this.assertActAsset(manager, actId, dto.assetId);
const catalog = dto.catalogItemId
@@ -418,6 +434,7 @@ export class InspectionFindingsService {
});
}
const finding = manager.getRepository(InspectionFinding).create({
id: dto.clientGeneratedId,
actId,
assetId: dto.assetId,
catalogItemId: catalog?.id ?? null,
@@ -28,6 +28,7 @@ import {
} from './inspection-report-workflow.service';
import { InspectionReportsService } from './inspection-reports.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActPdfService } from './inspection-act-pdf.service';
@Controller('inspection-reports')
export class InspectionReportsController {
@@ -148,3 +149,24 @@ export class InspectionActReportController {
return this.reports.generate(actId, principal, request);
}
}
@Controller('inspection-acts/:actId/pdf')
export class InspectionActPdfController {
constructor(private readonly pdf: InspectionActPdfService) {}
@Get()
@RequirePermissions('inspection_acts.read')
async content(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Res() response: Response,
) {
await this.pdf.ensure(actId);
const content = await this.pdf.content(actId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Length', String(content.buffer.length));
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
return response.send(content.buffer);
}
}
@@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
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 { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
@@ -16,6 +16,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
controllers: [
InspectionReportsController,
InspectionActReportController,
InspectionActPdfController,
InspectionDeadlineAdminController,
DocumentDeliveryController,
],
@@ -23,6 +23,10 @@ export class CreateFieldFindingDto {
@IsUUID('4')
actId!: string;
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsOptional()
@Transform(optionalText)
@IsUUID('4')
@@ -14,6 +14,10 @@ import {
} from 'class-validator';
export class CreateFieldInventoryDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4')
typeId!: string;
@@ -4,12 +4,17 @@ import {
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
export class UploadFieldInventoryPhotoDto {
@IsOptional()
@IsUUID('4')
operationId?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -101,13 +101,13 @@ export class F3FieldInventoryStructureService {
if (familyRequired && !dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado',
});
}
if (!familyRequired && dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
message: 'Este nivel estructural no utiliza familia técnica',
message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación',
});
}
const family = dto.familyId
@@ -116,7 +116,7 @@ export class F3FieldInventoryStructureService {
if (dto.familyId && !family) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_INVALID',
message: 'La familia técnica no es válida para el padre seleccionado',
message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado',
});
}
@@ -214,7 +214,7 @@ export class F3FieldInventoryStructureService {
if (!parent.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones',
});
}
return this.dataSource.query(`
@@ -99,6 +99,7 @@ export class FieldFindingsService {
});
}
const payload: CreateInspectionFindingDto = {
clientGeneratedId: dto.clientGeneratedId,
assetId,
catalogItemId: dto.catalogItemId ?? null,
customTitle: dto.customTitle ?? null,
@@ -278,7 +279,7 @@ export class FieldFindingsService {
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
message: 'La Instalación/Subinstalación debe tener su tipo definido antes de registrar Hallazgos',
});
}
if (!row.insideScope) {
@@ -273,6 +273,23 @@ export class FieldInventoryService {
request: RequestWithContext,
) {
const context = await this.requireVisitContext(visitId, principal, true);
if (dto.clientGeneratedId) {
const [existing] = await this.dataSource.query(`
SELECT discovery.asset_id AS "assetId", discovery.visit_id AS "visitId"
FROM asset_field_discoveries discovery
WHERE discovery.asset_id=$1::uuid
LIMIT 1
`, [dto.clientGeneratedId]) as Array<{ assetId: string; visitId: string }>;
if (existing) {
if (existing.visitId !== visitId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_CLIENT_ID_REUSED',
message: 'El identificador offline del Inventario ya fue utilizado en otra inspección',
});
}
return this.detail(visitId, existing.assetId, principal);
}
}
const parentId = dto.parentId ?? context.scopeAssetId;
await this.requireParentInContext(parentId, context);
@@ -286,6 +303,7 @@ export class FieldInventoryService {
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
const discoveryDto: CreateFieldDiscoveryDto = {
clientGeneratedId: dto.clientGeneratedId,
visitId,
code,
name: dto.name,
@@ -351,6 +369,28 @@ export class FieldInventoryService {
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context);
if (dto.operationId) {
const [existing] = await this.dataSource.query(`
SELECT media_id AS "mediaId", asset_id AS "assetId"
FROM asset_field_capture_events
WHERE client_operation_id=$1::uuid
LIMIT 1
`, [dto.operationId]) as Array<{ mediaId: string | null; assetId: string }>;
if (existing) {
if (existing.assetId !== assetId || !existing.mediaId) {
throw new ConflictException({
code: 'FIELD_PHOTO_OPERATION_REUSED',
message: 'La operación offline de fotografía ya fue utilizada en otro Inventario',
});
}
const media = (await this.media.list(assetId)).data.find((item) => item.id === existing.mediaId);
if (!media) throw new ConflictException({
code: 'FIELD_PHOTO_OPERATION_MEDIA_MISSING',
message: 'La fotografía sincronizada no está disponible',
});
return { media, capture: await this.captureStatus(visitId, assetId) };
}
}
if (!await this.isSelected(visitId, assetId)) {
throw new ConflictException({
code: 'FIELD_INVENTORY_NOT_SELECTED',
@@ -383,8 +423,8 @@ export class FieldInventoryService {
device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label,
exif_latitude, exif_longitude, exif_captured_at,
created_by
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
created_by, client_operation_id
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
`, [
visitId,
assetId,
@@ -398,6 +438,7 @@ export class FieldInventoryService {
dto.exifLongitude ?? null,
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
principal.userId,
dto.operationId ?? null,
]);
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-7';
export const API_PHASE = 'F6.7';
export const API_VERSION = '0.29.0-8';
export const API_PHASE = 'F6.8';
@@ -34,9 +34,9 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle'
assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/);
});
test('F4 Android ViewModel calls lock and seal directly', () => {
assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/);
assert.match(viewModel, /actsRepository\.seal\(actId\)/);
test('F6.8 Android ViewModel persists lock and seal through the offline queue', () => {
assert.match(viewModel, /offlineQueue\.enqueueActLock\(actId, urgency\)/);
assert.match(viewModel, /offlineQueue\.enqueueSeal\(actId\)/);
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/);
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.7 release', () => {
assert.equal(API_PHASE, 'F6.7');
test('health metadata reports the current F6.8 release', () => {
assert.equal(API_PHASE, 'F6.8');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version);
assert.equal(API_VERSION, '0.29.0-7');
assert.equal(API_VERSION, '0.29.0-8');
});
@@ -63,7 +63,7 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
assert.match(panel, /Todos para vincular/);
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
assert.match(panel, /Este tipo todavía no tiene Hallazgos asociados/);
});
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
@@ -116,7 +116,7 @@ test('F7 canonical detail uses one short everyday profile and keeps advanced adm
assert.match(detail, />Fotos</);
assert.match(detail, />Cambios</);
assert.match(detail, /Empresa operadora/);
assert.match(detail, /Tipo técnico/);
assert.match(detail, /Tipo de instalación \/ subinstalación/);
assert.match(detail, /AssetTechnicalDataPanel/);
assert.match(detail, /AssetDossierPanel/);
assert.match(detail, /Administración avanzada/);
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 37/);
assert.match(gradle, /versionName = "0\.19\.9"/);
assert.match(gradle, /versionCode = 38/);
assert.match(gradle, /versionName = "0\.19\.10"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/);
assert.match(version, /APP_PHASE\s*=\s*'F6\.8/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
@@ -49,7 +49,7 @@ test('F6.1 presentation keeps Android start, Otro and chronological merge flows
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
assert.match(overview, /"Iniciar inspección"/);
assert.match(overview, /Otro \/ no catalogado/);
assert.match(viewModel, /repository\.startVisit\(id\)/);
assert.match(viewModel, /enqueueVisitStart\(id\)|repository\.startVisit\(id\)/);
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
});
+2 -2
View File
@@ -36,11 +36,11 @@ test('F6.7 Android goes Draft to Para firmar to company manifestation without in
assert.match(labels, /"LOCKED" -> "Para firmar"/);
assert.match(android, /Cerrar contenido y pasar a firma/);
assert.match(android, /Firma del representante de la empresa \(acompañante\)/);
assert.match(android, /Aplicar mi firma y cerrar Acta/);
assert.match(android, /cerrar Acta/);
assert.doesNotMatch(android, /Firmá como inspector\/a/);
assert.doesNotMatch(android, /inspectorSigned/);
assert.doesNotMatch(android, /Text\(selected\.code/);
assert.match(android, /companyOutcome\?\.status == "ABSENT"/);
assert.match(android, /attendanceStatus == "ABSENT"/);
});
test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => {
assert.match(profile, /FIRMA DEL INSPECTOR/);
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.8 persists field mutations locally and retries only when network is available', () => {
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
assert.match(queue, /@Database\(entities = \[PendingMobileOperation::class, OfflineCacheEntry::class\]/);
assert.match(queue, /NetworkType\.CONNECTED/);
assert.match(queue, /OneTimeWorkRequestBuilder<OfflineSyncWorker>/);
for (const operation of [
'VISIT_START', 'VISIT_CLOSE', 'FIELD_ASSET_CREATE', 'FIELD_ASSET_PHOTO',
'ACT_CREATE', 'ACT_ASSET_ENSURE', 'FINDING_CREATE', 'FINDING_PHOTO',
'ACT_RESPONSIBLE', 'ACT_LOCK', 'COMPANY_OUTCOME', 'COMPANY_SIGNATURE', 'ACT_SEAL',
]) assert.match(queue, new RegExp(`"${operation}"`));
});
test('F6.8 isolates offline work and cached inspection data by authenticated user', () => {
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
const mobile = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
const findings = android('app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt');
assert.match(queue, /val ownerUserId: String/);
assert.match(queue, /ownerUserId=:ownerUserId/);
assert.match(queue, /SecureSessionStore/);
assert.match(mobile, /userCacheKey/);
assert.match(acts, /userCacheKey/);
assert.match(findings, /userCacheKey/);
});
test('F6.8 gives offline-created objects idempotent client identities', () => {
const actDto = api('src/inspection-acts/dto/create-inspection-act.dto.ts');
const findingDto = api('src/inspection-findings/dto/create-inspection-finding.dto.ts');
const inventoryDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts');
const migration = api('src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts');
assert.match(actDto, /clientGeneratedId/);
assert.match(findingDto, /clientGeneratedId/);
assert.match(inventoryDto, /clientGeneratedId/);
assert.match(migration, /inspection_finding_evidence/);
assert.match(migration, /asset_field_capture_events/);
assert.equal((migration.match(/client_operation_id/g) ?? []).length >= 6, true);
});
test('F6.8 reviews photos before queueing and never sends the retired ONLINE upload mode', () => {
const photo = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
assert.match(photo, /Revisar fotografía/);
assert.match(photo, /Usar esta foto/);
assert.match(photo, /Volver a tomar/);
assert.match(photo, /Eliminar foto/);
assert.doesNotMatch(acts, /uploadMode: String = "ONLINE"/);
assert.match(acts, /uploadMode: String = "IMMEDIATE"/);
});
test('F6.8 makes the real Acta PDF and photographic record first-class in Dashboard', () => {
const controller = api('src/inspection-reports/inspection-reports.controller.ts');
const page = web('src/pages/InspectionActEditorPage.tsx');
const media = web('src/features/inspections/InspectionActMediaPanel.tsx');
assert.match(controller, /inspection-acts\/:actId\/pdf/);
assert.match(api('src/inspection-acts/inspection-acts.controller.ts'), /:id\/field-media/);
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
assert.match(page, /Abrir PDF del Acta/);
assert.match(page, /Descargar PDF/);
assert.match(media, /Fotos y Hallazgos/);
assert.match(media, /Evidencia fotográfica/);
assert.match(media, /Fotos tomadas durante esta inspección/);
});
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
const config = web('src/pages/AssetTypesPage.tsx');
const detail = web('src/pages/SimpleInventoryDetailPage.tsx');
const field = api('src/inspection-visits/f3-field-inventory-structure.service.ts');
assert.match(config, /Tipos de Instalación/);
assert.match(config, /Tipos de Subinstalación/);
assert.match(detail, /Tipo de instalación \/ subinstalación/);
assert.match(field, /Tipo de instalación\/subinstalación/);
assert.doesNotMatch(config, /clasificación técnica/i);
assert.doesNotMatch(detail, /clasificación técnica/i);
});