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
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:
@@ -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) };
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user