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
@@ -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';