diff --git a/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts b/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts new file mode 100644 index 0000000..2197f9c --- /dev/null +++ b/api-v3/src/inspection-acts/mobile-inspection-acts.service.ts @@ -0,0 +1,102 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; + +/** + * Read model intentionally kept small for the field client. + * + * The Android app only needs the Act identity/lifecycle, deadlines and counters to + * open an Inspection. It must not fail because an office-document/report module is + * unavailable or temporarily schema-drifted. The canonical office endpoints keep + * their richer projection in InspectionActsService. + */ +@Injectable() +export class MobileInspectionActsService { + constructor(private readonly dataSource: DataSource) {} + + async listForVisit(visitId: string, query: ListInspectionActsQueryDto) { + const [visit] = (await this.dataSource.query( + 'SELECT id FROM inspection_visits WHERE id = $1::uuid', + [visitId], + )) as Array<{ id: string }>; + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); + } + + const conditions = ['act.visit_id = $1::uuid']; + const parameters: unknown[] = [visitId]; + const add = (value: unknown): string => { + parameters.push(value); + return `$${parameters.length}`; + }; + + if (query.search?.trim()) { + const search = add(`%${query.search.trim()}%`); + conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`); + } + if (query.status) conditions.push(`act.status = ${add(query.status)}`); + if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); + if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); + if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); + + const where = `WHERE ${conditions.join(' AND ')}`; + const [countRow] = (await this.dataSource.query( + `SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`, + parameters, + )) as Array<{ total: number }>; + const total = Number(countRow?.total ?? 0); + + const limit = add(query.pageSize); + const offset = add((query.page - 1) * query.pageSize); + const data = await this.dataSource.query(` + SELECT + act.id, + act.visit_id AS "visitId", + act.code, + act.status, + act.occurred_at AS "occurredAt", + 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_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", + act.current_version AS "currentVersion", + act.closed_at AS "closedAt", + act.closure_sha256 AS "closureSha256", + COALESCE(( + SELECT COUNT(*)::integer + FROM inspection_act_assets link + WHERE link.act_id = act.id AND link.included = true + ), 0)::integer AS "assetCount", + COALESCE(( + SELECT COUNT(*)::integer + FROM inspection_findings finding + WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + ), 0)::integer AS "findingCount" + FROM inspection_acts act + ${where} + ORDER BY act.act_year DESC, act.act_number DESC + LIMIT ${limit} OFFSET ${offset} + `, parameters); + + return { + data, + meta: { + page: query.page, + pageSize: query.pageSize, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), + }, + }; + } +}