Initial commit: proyecto ContabilidadSaPolar completo
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
# ============================================================
|
||||||
|
# CONFIGURACIÓN DE ENTORNO - Sa Polar
|
||||||
|
# Copia este archivo a .env y ajusta los valores.
|
||||||
|
# NUNCA subas .env al repositorio (está en .gitignore).
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Base de datos MySQL
|
||||||
|
DB_NAME=sa_polar
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=root
|
||||||
|
|
||||||
|
# JWT: genera un secreto con: openssl rand -base64 64
|
||||||
|
# ⚠️ CAMBIAR en producción. Mínimo 256 bits (32 bytes).
|
||||||
|
JWT_SECRET=a2V5X3N1cGVyX3NlY3JldGFfcGFyYV9sb2dpbl9kZV9zYV9wb2xhcl9kZWJlc19zZXJfZGUzMl9jYXJhY3RlcmVz
|
||||||
|
|
||||||
|
# SMTP (email)
|
||||||
|
MAIL_HOST=localhost
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
MAIL_SMTP_AUTH=false
|
||||||
|
MAIL_SMTP_STARTTLS=false
|
||||||
|
RECEIPT_FROM_EMAIL=noreply@sapolar.com
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
UPLOAD_PATH=./uploads
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Java
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
target/
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
|
||||||
|
# Maven
|
||||||
|
.mvn/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
.project
|
||||||
|
opencode.json
|
||||||
|
.opencode
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Workflow
|
||||||
|
|
||||||
|
After every code change:
|
||||||
|
|
||||||
|
1. **Build backend**: `cd backend && mvn clean compile`
|
||||||
|
2. **Build frontend**: `cd frontend && cmd /c "npm run build"`
|
||||||
|
|
||||||
|
Si alguna compilación falla, corregir antes de continuar.
|
||||||
|
|
||||||
|
> ⚠️ **NUNCA usar `-v` en docker compose.** Borrar volúmenes elimina TODOS los datos de la BBDD. El despliegue con Docker lo gestiona el usuario manualmente.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM maven:3.9-eclipse-temurin-21-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY backend/pom.xml backend/pom.xml
|
||||||
|
RUN mvn dependency:go-offline -B -pl backend -am
|
||||||
|
COPY backend/ backend/
|
||||||
|
RUN mvn package -B -pl backend -DskipTests
|
||||||
|
RUN java -Djarmode=layertools -jar backend/target/*.jar extract --destination extracted
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
|
||||||
|
COPY --from=builder /app/extracted/dependencies/ ./
|
||||||
|
COPY --from=builder /app/extracted/spring-boot-loader/ ./
|
||||||
|
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
|
||||||
|
COPY --from=builder /app/extracted/application/ ./
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads && chown -R appuser:appgroup /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY <<EOF /etc/nginx/conf.d/default.conf
|
||||||
|
server {
|
||||||
|
listen 3000;
|
||||||
|
charset utf-8;
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files \$uri \$uri/ /index.html;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Authorization \$http_authorization;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Resumen del Proyecto
|
||||||
|
|
||||||
|
## Fase actual: V11 (COMPLETADA) — Refactor Financiero
|
||||||
|
|
||||||
|
### Estado actual (Julio 2026)
|
||||||
|
|
||||||
|
El proyecto **Sa Polar** está operativo con Docker Compose y todos los endpoints verificados.
|
||||||
|
|
||||||
|
### Última sesión — Refactor V11: Dividir Ingresos/Gastos en Recibos y Plantillas
|
||||||
|
|
||||||
|
**Objetivo:** Migrar del modelo plano `incomes`/`expenses` a un modelo más escalable con recibos de ingresos, plantillas de gastos y recibos de gastos.
|
||||||
|
|
||||||
|
#### Cambios realizados:
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Creadas 3 nuevas entidades: `IncomeReceipt`, `ExpenseTemplate`, `ExpenseReceipt`
|
||||||
|
- Creados repositorios: `IncomeReceiptRepository`, `ExpenseTemplateRepository`, `ExpenseReceiptRepository`
|
||||||
|
- Creados servicios: `IncomeReceiptService`, `ExpenseTemplateService`, `ExpenseReceiptService`
|
||||||
|
- Creados controladores: `IncomeReceiptController`, `ExpenseTemplateController`, `ExpenseReceiptController`
|
||||||
|
- Creado `ExpenseScheduler` para generación automática de recibos desde plantillas
|
||||||
|
- Refactorizados: `PdfReceiptService`, `EmailReceiptService`, `ReceiptService`, `ReceiptController`, `ReportService`, `DashboardService`, `MaintenanceService`, `EmailLog` (income_id → income_receipt_id)
|
||||||
|
- Eliminadas entidades antiguas: `Income.java`, `Expense.java` y sus repos, servicios, controladores
|
||||||
|
|
||||||
|
**Base de datos:**
|
||||||
|
- Creada migración V11 con 3 nuevas tablas y modificación de `email_log`
|
||||||
|
- Actualizados `db/init.sql` y `db/seed.sql`
|
||||||
|
- 25 tablas total (antes 23)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Creadas páginas: `IncomeReceipts.tsx`, `ExpenseTemplates.tsx`, `ExpenseReceipts.tsx`
|
||||||
|
- Actualizados: `Layout.tsx`, `App.tsx`, `api.ts`, `resources.ts`
|
||||||
|
- Eliminadas páginas antiguas: `Incomes.tsx`, `Expenses.tsx`
|
||||||
|
|
||||||
|
**Infraestructura:**
|
||||||
|
- `docker compose down -v && docker compose up -d --build` exitoso
|
||||||
|
- Login verificado exitosamente con admin/admin123
|
||||||
|
|
||||||
|
### Pendiente para V12:
|
||||||
|
- Terminar páginas avanzadas de frontend (IncomeReceipts, ExpenseTemplates, ExpenseReceipts con buscadores, filtros, etc.)
|
||||||
|
- Funcionalidad de inventario
|
||||||
|
- Candidatos
|
||||||
|
- Gestión de temporada
|
||||||
|
- Tests, CI/CD
|
||||||
|
|
||||||
|
### Credenciales
|
||||||
|
- **Usuario:** `admin` | **Contraseña:** `admin123`
|
||||||
|
|
||||||
|
### Comandos rápidos
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # Desplegar todo
|
||||||
|
docker compose logs backend -f # Logs backend
|
||||||
|
docker compose down -v # Borrar datos y reiniciar
|
||||||
|
```
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Sa Polar - Sistema de Gestión de Alquileres
|
||||||
|
|
||||||
|
Sistema integral para la administración y contabilidad de alquileres de inmuebles. Permite gestionar propiedades, conjuntos, contratos, inquilinos, ingresos, gastos, incidencias, mantenimiento programado y recibos automáticos.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Componente | Tecnología |
|
||||||
|
|------------|------------|
|
||||||
|
| Backend | Java 21, Spring Boot 3.4.1, Maven |
|
||||||
|
| Frontend | React 19, TypeScript 6, Vite 8 |
|
||||||
|
| Base de datos | MySQL 8 |
|
||||||
|
| Autenticación | JWT (jjwt 0.12.6) |
|
||||||
|
| Seguridad | Spring Security con roles (ADMIN, GERENTE, CONTABLE, VISUALIZADOR) |
|
||||||
|
| PDF | iText 8.0.5 |
|
||||||
|
| Excel | Apache POI 5.3.0 |
|
||||||
|
| Documentación API | SpringDoc OpenAPI 2.7.0 |
|
||||||
|
| Contenedores | Docker + docker-compose |
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
Monolito modular con frontend separado:
|
||||||
|
|
||||||
|
```
|
||||||
|
cliente (browser)
|
||||||
|
│
|
||||||
|
├── http://localhost:3000 (Nginx - producción)
|
||||||
|
└── http://localhost:5173 (Vite - desarrollo)
|
||||||
|
│
|
||||||
|
└── /api/* ────→ backend:8080 (Spring Boot)
|
||||||
|
│
|
||||||
|
└── mysql:3306 (MySQL 8)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Docker y docker-compose
|
||||||
|
- O en su defecto: Java 21 + Node 22 + MySQL 8
|
||||||
|
|
||||||
|
## Inicio Rápido (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clonar repositorio
|
||||||
|
git clone <repo-url> sa-polar
|
||||||
|
cd sa-polar
|
||||||
|
|
||||||
|
# 2. Copiar configuración de entorno
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. Iniciar todos los servicios
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Acceder a la aplicación
|
||||||
|
# Frontend: http://localhost:3000
|
||||||
|
# Backend API: http://localhost:8080
|
||||||
|
# Swagger UI: http://localhost:8080/swagger-ui.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credenciales por Defecto
|
||||||
|
|
||||||
|
| Usuario | Contraseña | Rol |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| admin | admin123 | ADMIN |
|
||||||
|
|
||||||
|
## Desarrollo Local (sin Docker)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requiere MySQL 8 corriendo en localhost:3306 con base de datos "sa_polar"
|
||||||
|
cd backend
|
||||||
|
mvn spring-boot:run -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# Acceder en http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura del Proyecto
|
||||||
|
|
||||||
|
```
|
||||||
|
sa-polar/
|
||||||
|
├── backend/ # Backend Spring Boot
|
||||||
|
│ └── src/main/java/com/sapolar/
|
||||||
|
│ ├── auth/ # Autenticación JWT
|
||||||
|
│ ├── common/ # DTOs, excepciones, utilidades
|
||||||
|
│ ├── config/ # Configuraciones (seguridad, CORS, OpenAPI)
|
||||||
|
│ ├── contract/ # Gestión de contratos
|
||||||
|
│ ├── dashboard/ # Dashboard y resúmenes
|
||||||
|
│ ├── document/ # Gestión de documentos adjuntos
|
||||||
|
│ ├── finance/ # Módulo financiero
|
||||||
|
│ │ ├── expense/ # Gastos
|
||||||
|
│ │ ├── income/ # Ingresos
|
||||||
|
│ │ └── receipt/ # Recibos, PDF, email, reportes
|
||||||
|
│ ├── incident/ # Incidencias
|
||||||
|
│ ├── maintenance/ # Mantenimiento programado
|
||||||
|
│ ├── notification/ # Notificaciones
|
||||||
|
│ ├── property/ # Propiedades, inmuebles y conjuntos
|
||||||
|
│ ├── tenant/ # Inquilinos/arrendatarios
|
||||||
|
│ └── user/ # Usuarios y roles
|
||||||
|
├── db/
|
||||||
|
│ └── init.sql # DDL + datos semilla
|
||||||
|
├── frontend/ # Frontend React
|
||||||
|
│ └── src/
|
||||||
|
│ ├── api/ # Cliente Axios, funciones API
|
||||||
|
│ ├── components/ # Componentes (Layout)
|
||||||
|
│ ├── contexts/ # AuthContext
|
||||||
|
│ ├── pages/ # Páginas (Login, Dashboard, etc.)
|
||||||
|
│ └── types/ # Tipos TypeScript
|
||||||
|
├── docs/ # Documentación
|
||||||
|
├── docker-compose.yml # Orquestación de servicios
|
||||||
|
├── Dockerfile.backend # Build multi-etapa backend
|
||||||
|
└── Dockerfile.frontend # Build multi-etapa frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
La documentación completa está disponible en el directorio `docs/`:
|
||||||
|
|
||||||
|
- [Índice de documentación](docs/INDEX.md)
|
||||||
|
- [Arquitectura del sistema](docs/tecnicas/arquitectura.md)
|
||||||
|
- [Referencia de API](docs/tecnicas/api.md)
|
||||||
|
- [Esquema de base de datos](docs/tecnicas/base-de-datos.md)
|
||||||
|
- [Planificación y roadmap](docs/planificacion/roadmap.md)
|
||||||
|
- [Manual de usuario](docs/usuario/manual.md)
|
||||||
|
|
||||||
|
## API Endpoints Principales
|
||||||
|
|
||||||
|
| Grupo | Base Path | Métodos |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Autenticación | `/api/auth` | login, register, refresh |
|
||||||
|
| Usuarios | `/api/users` | CRUD (solo ADMIN) |
|
||||||
|
| Propiedades | `/api/properties` | CRUD + árbol + historial |
|
||||||
|
| Conjuntos | `/api/property-groups` | CRUD + propiedades asociadas |
|
||||||
|
| Inquilinos | `/api/tenants` | CRUD + búsqueda |
|
||||||
|
| Contratos | `/api/contracts` | CRUD + terminación |
|
||||||
|
| Ingresos | `/api/incomes` | CRUD + registro de pago |
|
||||||
|
| Gastos | `/api/expenses` | CRUD |
|
||||||
|
| Incidencias | `/api/incidents` | CRUD + asignación + programación |
|
||||||
|
| Mantenimiento | `/api/maintenance` | CRUD + programado |
|
||||||
|
| Recibos | `/api/receipts` | Generación, PDF, email, reportes |
|
||||||
|
| Notificaciones | `/api/notifications` | Listado, marcar leídas |
|
||||||
|
| Dashboard | `/api/dashboard` | Resúmenes y gráficos |
|
||||||
|
| Documentos | `/api/documents` | Subida, descarga |
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
| Variable | Defecto | Descripción |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DB_NAME` | `sa_polar` | Nombre de la base de datos |
|
||||||
|
| `DB_USER` | `root` | Usuario MySQL |
|
||||||
|
| `DB_PASSWORD` | `root` | Contraseña MySQL |
|
||||||
|
| `JWT_SECRET` | (por defecto) | Secreto para firmar JWT (base64) |
|
||||||
|
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Orígenes CORS permitidos |
|
||||||
|
| `UPLOAD_PATH` | `./uploads` | Ruta de almacenamiento de archivos |
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
Uso interno.
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.sapolar</groupId>
|
||||||
|
<artifactId>contabilidad-sa-polar</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>sa-polar-backend</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<spring-boot.version>3.4.1</spring-boot.version>
|
||||||
|
<jjwt.version>0.12.6</jjwt.version>
|
||||||
|
<springdoc.version>2.7.0</springdoc.version>
|
||||||
|
<mapstruct.version>1.6.3</mapstruct.version>
|
||||||
|
<lombok.version>1.18.36</lombok.version>
|
||||||
|
<itext.version>8.0.5</itext.version>
|
||||||
|
<poi.version>5.3.0</poi.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-dependencies</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starters -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Database -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-mysql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OpenAPI / Swagger -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MapStruct -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson Hibernate Module -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-hibernate6</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Reports: PDF -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.itextpdf</groupId>
|
||||||
|
<artifactId>kernel</artifactId>
|
||||||
|
<version>${itext.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.itextpdf</groupId>
|
||||||
|
<artifactId>layout</artifactId>
|
||||||
|
<version>${itext.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Reports: Excel -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>${poi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>21</source>
|
||||||
|
<target>21</target>
|
||||||
|
<parameters>true</parameters>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.sapolar;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
public class SaPolarApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(SaPolarApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
import com.sapolar.auth.dto.LoginRequest;
|
||||||
|
import com.sapolar.auth.dto.RegisterRequest;
|
||||||
|
import com.sapolar.auth.dto.TokenResponse;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<ApiResponse<TokenResponse>> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
TokenResponse response = authService.login(request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Inicio de sesión exitoso", response));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<ApiResponse<TokenResponse>> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
TokenResponse response = authService.register(request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Usuario registrado exitosamente", response));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<ApiResponse<TokenResponse>> refresh(@RequestBody Map<String, String> request) {
|
||||||
|
String refreshToken = request.get("refreshToken");
|
||||||
|
TokenResponse response = authService.refresh(refreshToken);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Token refrescado exitosamente", response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
import com.sapolar.auth.dto.LoginRequest;
|
||||||
|
import com.sapolar.auth.dto.RegisterRequest;
|
||||||
|
import com.sapolar.auth.dto.TokenResponse;
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.DuplicateResourceException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.user.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public TokenResponse login(LoginRequest request) {
|
||||||
|
authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
|
||||||
|
);
|
||||||
|
|
||||||
|
User user = userRepository.findByUsername(request.getUsername())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Usuario", 0L));
|
||||||
|
|
||||||
|
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||||
|
user.getId(), user.getUsername(), user.getRole().getName());
|
||||||
|
String refreshToken = jwtTokenProvider.generateRefreshToken(
|
||||||
|
user.getId(), user.getUsername());
|
||||||
|
|
||||||
|
return new TokenResponse(accessToken, refreshToken, user.getId(),
|
||||||
|
user.getUsername(), user.getRole().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public TokenResponse register(RegisterRequest request) {
|
||||||
|
if (userRepository.existsByUsername(request.getUsername())) {
|
||||||
|
throw new DuplicateResourceException("El usuario ya existe: " + request.getUsername());
|
||||||
|
}
|
||||||
|
if (userRepository.existsByEmail(request.getEmail())) {
|
||||||
|
throw new DuplicateResourceException("El email ya está registrado: " + request.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
String roleName = request.getRoleName() != null ? request.getRoleName() : "VISUALIZADOR";
|
||||||
|
Role role = roleRepository.findByName(roleName)
|
||||||
|
.orElseThrow(() -> new BadRequestException("Rol no válido: " + roleName));
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(request.getUsername());
|
||||||
|
user.setEmail(request.getEmail());
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setFullName(request.getFullName());
|
||||||
|
user.setPhone(request.getPhone());
|
||||||
|
user.setRole(role);
|
||||||
|
user.setActive(true);
|
||||||
|
user = userRepository.save(user);
|
||||||
|
|
||||||
|
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||||
|
user.getId(), user.getUsername(), user.getRole().getName());
|
||||||
|
String refreshToken = jwtTokenProvider.generateRefreshToken(
|
||||||
|
user.getId(), user.getUsername());
|
||||||
|
|
||||||
|
return new TokenResponse(accessToken, refreshToken, user.getId(),
|
||||||
|
user.getUsername(), user.getRole().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public TokenResponse refresh(String refreshToken) {
|
||||||
|
if (!jwtTokenProvider.validateToken(refreshToken)) {
|
||||||
|
throw new BadRequestException("Token de refresco inválido o expirado");
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Usuario", userId));
|
||||||
|
|
||||||
|
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||||
|
user.getId(), user.getUsername(), user.getRole().getName());
|
||||||
|
String newRefreshToken = jwtTokenProvider.generateRefreshToken(
|
||||||
|
user.getId(), user.getUsername());
|
||||||
|
|
||||||
|
return new TokenResponse(accessToken, newRefreshToken, user.getId(),
|
||||||
|
user.getUsername(), user.getRole().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import com.sapolar.user.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("Usuario no encontrado: " + username));
|
||||||
|
|
||||||
|
return new org.springframework.security.core.userdetails.User(
|
||||||
|
user.getUsername(),
|
||||||
|
user.getPasswordHash(),
|
||||||
|
user.getActive(),
|
||||||
|
true, true, true,
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().getName()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
String token = extractToken(request);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||||
|
String username = jwtTokenProvider.getUsernameFromToken(token);
|
||||||
|
Long userId = jwtTokenProvider.getUserIdFromToken(token);
|
||||||
|
String role = jwtTokenProvider.getRoleFromToken(token);
|
||||||
|
|
||||||
|
List<SimpleGrantedAuthority> authorities = List.of(
|
||||||
|
new SimpleGrantedAuthority("ROLE_" + role)
|
||||||
|
);
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
new SecurityUser(userId, username, role),
|
||||||
|
null,
|
||||||
|
authorities
|
||||||
|
);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String bearer = request.getHeader("Authorization");
|
||||||
|
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
|
||||||
|
return bearer.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.io.Decoders;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtTokenProvider {
|
||||||
|
|
||||||
|
private final SecretKey secretKey;
|
||||||
|
private final long expirationMs;
|
||||||
|
private final long refreshExpirationMs;
|
||||||
|
|
||||||
|
public JwtTokenProvider(
|
||||||
|
@Value("${app.jwt.secret}") String secret,
|
||||||
|
@Value("${app.jwt.expiration-ms}") long expirationMs,
|
||||||
|
@Value("${app.jwt.refresh-expiration-ms}") long refreshExpirationMs) {
|
||||||
|
this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
|
||||||
|
this.expirationMs = expirationMs;
|
||||||
|
this.refreshExpirationMs = refreshExpirationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateAccessToken(Long userId, String username, String role) {
|
||||||
|
Date now = new Date();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.claim("userId", userId)
|
||||||
|
.claim("role", role)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(new Date(now.getTime() + expirationMs))
|
||||||
|
.signWith(secretKey)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateRefreshToken(Long userId, String username) {
|
||||||
|
Date now = new Date();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.claim("userId", userId)
|
||||||
|
.claim("type", "refresh")
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(new Date(now.getTime() + refreshExpirationMs))
|
||||||
|
.signWith(secretKey)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsernameFromToken(String token) {
|
||||||
|
return parseClaims(token).getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserIdFromToken(String token) {
|
||||||
|
return parseClaims(token).get("userId", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRoleFromToken(String token) {
|
||||||
|
return parseClaims(token).get("role", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean validateToken(String token) {
|
||||||
|
try {
|
||||||
|
parseClaims(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims parseClaims(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.sapolar.auth;
|
||||||
|
|
||||||
|
public record SecurityUser(Long userId, String username, String role) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sapolar.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class LoginRequest {
|
||||||
|
@NotBlank(message = "El usuario es obligatorio")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "La contraseña es obligatoria")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.sapolar.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class RegisterRequest {
|
||||||
|
@NotBlank(message = "El usuario es obligatorio")
|
||||||
|
@Size(min = 3, max = 50, message = "El usuario debe tener entre 3 y 50 caracteres")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@NotBlank(message = "El email es obligatorio")
|
||||||
|
@Email(message = "Email no válido")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank(message = "La contraseña es obligatoria")
|
||||||
|
@Size(min = 6, message = "La contraseña debe tener al menos 6 caracteres")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@NotBlank(message = "El nombre completo es obligatorio")
|
||||||
|
private String fullName;
|
||||||
|
|
||||||
|
private String phone;
|
||||||
|
private String roleName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.sapolar.auth.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TokenResponse {
|
||||||
|
private String accessToken;
|
||||||
|
private String refreshToken;
|
||||||
|
private String tokenType = "Bearer";
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
public TokenResponse(String accessToken, String refreshToken, Long userId, String username, String role) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.tokenType = "Bearer";
|
||||||
|
this.userId = userId;
|
||||||
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.sapolar.common.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
private LocalDateTime timestamp = LocalDateTime.now();
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return new ApiResponse<>(true, "OK", data, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return new ApiResponse<>(true, message, data, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApiResponse<Void> success(String message) {
|
||||||
|
return new ApiResponse<>(true, message, null, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
return new ApiResponse<>(false, message, null, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.sapolar.common.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PagedResponse<T> {
|
||||||
|
private List<T> content;
|
||||||
|
private int page;
|
||||||
|
private int size;
|
||||||
|
private long totalElements;
|
||||||
|
private int totalPages;
|
||||||
|
private boolean last;
|
||||||
|
|
||||||
|
public static <T> PagedResponse<T> from(Page<T> page) {
|
||||||
|
return new PagedResponse<>(
|
||||||
|
page.getContent(),
|
||||||
|
page.getNumber(),
|
||||||
|
page.getSize(),
|
||||||
|
page.getTotalElements(),
|
||||||
|
page.getTotalPages(),
|
||||||
|
page.isLast()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sapolar.common.exception;
|
||||||
|
|
||||||
|
public class BadRequestException extends RuntimeException {
|
||||||
|
public BadRequestException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sapolar.common.exception;
|
||||||
|
|
||||||
|
public class DuplicateResourceException extends RuntimeException {
|
||||||
|
public DuplicateResourceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.sapolar.common.exception;
|
||||||
|
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(ResourceNotFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNotFound(ResourceNotFoundException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.error(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(BadRequestException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleBadRequest(BadRequestException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body(ApiResponse.error(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DuplicateResourceException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleDuplicate(DuplicateResourceException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(ApiResponse.error(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error("Acceso denegado"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
String errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(FieldError::getDefaultMessage)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body(ApiResponse.error(errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleGeneral(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(ApiResponse.error("Error interno del servidor: " + ex.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sapolar.common.exception;
|
||||||
|
|
||||||
|
public class ResourceNotFoundException extends RuntimeException {
|
||||||
|
public ResourceNotFoundException(String resource, Long id) {
|
||||||
|
super(resource + " no encontrado con id: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResourceNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.sapolar.common.util;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@MappedSuperclass
|
||||||
|
public abstract class BaseEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig {
|
||||||
|
|
||||||
|
@Value("${app.cors.allowed-origins}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsFilter corsFilter() {
|
||||||
|
List<String> origins = List.of(allowedOrigins.split(","));
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
config.setAllowedOrigins(origins);
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
config.setExposedHeaders(List.of("Authorization", "Content-Disposition"));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return new CorsFilter(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "app.upload")
|
||||||
|
public class FileStorageConfig {
|
||||||
|
|
||||||
|
private String path;
|
||||||
|
private long maxFileSize;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
Path uploadPath = Paths.get(path);
|
||||||
|
if (!Files.exists(uploadPath)) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(uploadPath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("No se pudo crear el directorio de uploads: " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import org.flywaydb.core.Flyway;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estrategia de migración de Flyway según el perfil de Spring.
|
||||||
|
*
|
||||||
|
* - Perfil "dev" (desarrollo): solo repair + migrate. NUNCA hace clean
|
||||||
|
* para preservar los datos de desarrollo.
|
||||||
|
*
|
||||||
|
* - Perfil "prod" (producción): SOLO hace repair + migrate.
|
||||||
|
* Si repair no es suficiente, falla y debe intervenir un administrador.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class FlywayRepairConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FlywayRepairConfig.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estrategia para desarrollo: solo repair + migrate.
|
||||||
|
* NUNCA hace clean porque borraría datos.
|
||||||
|
* Activada cuando el perfil activo es "dev" o no hay perfil definido.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile({"dev", "default"})
|
||||||
|
public FlywayMigrationStrategy devFlywayMigrationStrategy() {
|
||||||
|
return flyway -> {
|
||||||
|
try {
|
||||||
|
log.info("Flyway [dev]: ejecutando repair + migrate");
|
||||||
|
flyway.repair();
|
||||||
|
flyway.migrate();
|
||||||
|
log.info("Flyway [dev]: migraciones aplicadas correctamente");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Flyway [dev]: repair + migrate falló: {}", e.getMessage());
|
||||||
|
log.error("Flyway [dev]: NO se ejecutará clean para preservar datos. Revise el problema manualmente.");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estrategia para producción: NUNCA hace clean. Solo repair + migrate.
|
||||||
|
* Si repair no es suficiente, falla y debe intervenir un administrador.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("prod")
|
||||||
|
public FlywayMigrationStrategy prodFlywayMigrationStrategy(Environment env) {
|
||||||
|
return flyway -> {
|
||||||
|
log.info("Flyway [prod]: ejecutando repair + migrate");
|
||||||
|
try {
|
||||||
|
flyway.repair();
|
||||||
|
flyway.migrate();
|
||||||
|
log.info("Flyway [prod]: migraciones aplicadas correctamente");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Flyway [prod]: ERROR en migraciones. NO se ejecutará clean.", e);
|
||||||
|
log.error("Flyway [prod]: Revise manualmente el esquema y flyway_schema_history.");
|
||||||
|
log.error("Flyway [prod]: Si necesita reparar, use: mvn flyway:repair");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Hibernate6Module hibernate6Module() {
|
||||||
|
Hibernate6Module module = new Hibernate6Module();
|
||||||
|
module.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION);
|
||||||
|
module.enable(Hibernate6Module.Feature.FORCE_LAZY_LOADING);
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI customOpenAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("Sa Polar API - Gestión de Alquileres")
|
||||||
|
.version("1.0.0")
|
||||||
|
.description("API REST para la gestión y contabilidad de una empresa de alquileres")
|
||||||
|
.contact(new Contact()
|
||||||
|
.name("Sa Polar")
|
||||||
|
.email("info@sapolar.com"))
|
||||||
|
.license(new License()
|
||||||
|
.name("MIT")
|
||||||
|
.url("https://opensource.org/licenses/MIT")))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes("Bearer",
|
||||||
|
new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("JWT")
|
||||||
|
.description("Ingrese el token JWT")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.sapolar.config;
|
||||||
|
|
||||||
|
import com.sapolar.auth.JwtAuthenticationFilter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthFilter;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.cors(cors -> cors.configure(http))
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
.requestMatchers("/api-docs/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/users/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/properties/**").hasAnyRole("ADMIN", "GERENTE")
|
||||||
|
.requestMatchers("/api/tenants/**").hasAnyRole("ADMIN", "GERENTE")
|
||||||
|
.requestMatchers("/api/contracts/**").hasAnyRole("ADMIN", "GERENTE")
|
||||||
|
.requestMatchers("/api/incomes/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/expenses/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/incidents/**").hasAnyRole("ADMIN", "GERENTE")
|
||||||
|
.requestMatchers("/api/maintenance/**").hasAnyRole("ADMIN", "GERENTE")
|
||||||
|
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/receipts/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "GERENTE", "CONTABLE")
|
||||||
|
.requestMatchers("/api/notifications/**").authenticated()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "contracts", indexes = {
|
||||||
|
@Index(name = "idx_contracts_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_contracts_status", columnList = "status_id"),
|
||||||
|
@Index(name = "idx_contracts_dates", columnList = "start_date, end_date")
|
||||||
|
})
|
||||||
|
public class Contract {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id", nullable = false)
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<ContractTenant> contractTenants = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "contract", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<ExpenseRepercussion> expenseRepercussions = new ArrayList<>();
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "status_id", nullable = false)
|
||||||
|
private ContractStatus status;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "period_id", nullable = false)
|
||||||
|
private PaymentPeriod paymentPeriod;
|
||||||
|
|
||||||
|
@Column(name = "contract_number", unique = true, length = 50)
|
||||||
|
private String contractNumber;
|
||||||
|
|
||||||
|
@Column(name = "start_date", nullable = false)
|
||||||
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
@Column(name = "end_date")
|
||||||
|
private LocalDate endDate;
|
||||||
|
|
||||||
|
@Column(name = "renewal_date")
|
||||||
|
private LocalDate renewalDate;
|
||||||
|
|
||||||
|
@Column(name = "rental_amount", nullable = false, precision = 12, scale = 2)
|
||||||
|
private BigDecimal rentalAmount;
|
||||||
|
|
||||||
|
@Column(name = "deposit_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal depositAmount;
|
||||||
|
|
||||||
|
@Column(name = "payment_day", nullable = false)
|
||||||
|
private Integer paymentDay = 1;
|
||||||
|
|
||||||
|
@Column(name = "payment_day_end")
|
||||||
|
private Integer paymentDayEnd;
|
||||||
|
|
||||||
|
@Column(name = "iban_charge", length = 34)
|
||||||
|
private String ibanCharge;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "signed_at")
|
||||||
|
private LocalDate signedAt;
|
||||||
|
|
||||||
|
@Column(name = "terminated_at")
|
||||||
|
private LocalDate terminatedAt;
|
||||||
|
|
||||||
|
@Column(name = "termination_cause", length = 500)
|
||||||
|
private String terminationCause;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private User createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.common.dto.PagedResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/contracts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContractController {
|
||||||
|
|
||||||
|
private final ContractService contractService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<PagedResponse<Contract>>> findAll(
|
||||||
|
@RequestParam(required = false) Long propertyId,
|
||||||
|
@RequestParam(required = false) Long tenantId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "id") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String dir) {
|
||||||
|
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
|
||||||
|
Pageable pageable = PageRequest.of(page, size, sorting);
|
||||||
|
if (propertyId != null) {
|
||||||
|
List<Contract> list = contractService.findByProperty(propertyId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
if (tenantId != null) {
|
||||||
|
List<Contract> list = contractService.findByTenant(tenantId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(contractService.findAll(pageable))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Contract>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(contractService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<Contract>> create(@RequestBody Contract contract,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Contrato creado",
|
||||||
|
contractService.create(contract, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Contract>> update(@PathVariable Long id, @RequestBody Contract contract) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Contrato actualizado",
|
||||||
|
contractService.update(id, contract)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/terminate")
|
||||||
|
public ResponseEntity<ApiResponse<Contract>> terminate(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String cause = body.getOrDefault("cause", "Rescisión");
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Contrato rescindido",
|
||||||
|
contractService.terminate(id, cause)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ContractRepository extends JpaRepository<Contract, Long> {
|
||||||
|
List<Contract> findByPropertyId(Long propertyId);
|
||||||
|
List<Contract> findByStatusId(Integer statusId);
|
||||||
|
Optional<Contract> findByContractNumber(String contractNumber);
|
||||||
|
|
||||||
|
@Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.property.id = :propertyId")
|
||||||
|
Optional<Contract> findActiveByPropertyId(@Param("propertyId") Long propertyId);
|
||||||
|
|
||||||
|
@Query("SELECT c FROM Contract c WHERE c.status.name = 'ACTIVO' AND c.endDate BETWEEN :start AND :end")
|
||||||
|
List<Contract> findContractsExpiringBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||||
|
|
||||||
|
boolean existsByContractNumber(String contractNumber);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.DuplicateResourceException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.property.PropertyStatus;
|
||||||
|
import com.sapolar.property.PropertyStatusRepository;
|
||||||
|
import com.sapolar.tenant.Tenant;
|
||||||
|
import com.sapolar.tenant.TenantRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContractService {
|
||||||
|
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
private final ContractTenantRepository contractTenantRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
private final PropertyStatusRepository propertyStatusRepository;
|
||||||
|
|
||||||
|
public List<Contract> findAll() {
|
||||||
|
return contractRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<Contract> findAll(Pageable pageable) {
|
||||||
|
return contractRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Contract> findByProperty(Long propertyId) {
|
||||||
|
return contractRepository.findByPropertyId(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Contract> findByTenant(Long tenantId) {
|
||||||
|
List<ContractTenant> cts = contractTenantRepository.findByTenantId(tenantId);
|
||||||
|
return cts.stream().map(ContractTenant::getContract).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Contract findById(Long id) {
|
||||||
|
return contractRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Contrato", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateNoOverlap(LocalDate newStart, LocalDate newEnd, Long propertyId, Long excludeId) {
|
||||||
|
LocalDate e2 = newEnd != null ? newEnd : LocalDate.MAX;
|
||||||
|
List<Contract> existing = contractRepository.findByPropertyId(propertyId);
|
||||||
|
for (Contract c : existing) {
|
||||||
|
if (c.getId().equals(excludeId)) continue;
|
||||||
|
LocalDate e1 = c.getEndDate() != null ? c.getEndDate() : LocalDate.MAX;
|
||||||
|
if (!newStart.isAfter(e1) && !c.getStartDate().isAfter(e2)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"La propiedad ya tiene un contrato (" + c.getContractNumber() + ") vigente en esas fechas");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Contract create(Contract contract, Long userId) {
|
||||||
|
if (contract.getProperty() == null || contract.getProperty().getId() == null) {
|
||||||
|
throw new BadRequestException("La propiedad es obligatoria");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.getContractNumber() != null &&
|
||||||
|
contractRepository.existsByContractNumber(contract.getContractNumber())) {
|
||||||
|
throw new DuplicateResourceException("Ya existe un contrato con número: " + contract.getContractNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
Property property = propertyRepository.findById(contract.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", contract.getProperty().getId()));
|
||||||
|
contract.setProperty(property);
|
||||||
|
|
||||||
|
validateNoOverlap(contract.getStartDate(), contract.getEndDate(), property.getId(), -1L);
|
||||||
|
|
||||||
|
List<ContractTenant> resolved = new ArrayList<>();
|
||||||
|
for (ContractTenant ct : contract.getContractTenants()) {
|
||||||
|
Tenant tenant = tenantRepository.findById(ct.getTenant().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId()));
|
||||||
|
ct.setTenant(tenant);
|
||||||
|
ct.setContract(contract);
|
||||||
|
resolved.add(ct);
|
||||||
|
}
|
||||||
|
contract.setContractTenants(resolved);
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
contract.setCreatedBy(user);
|
||||||
|
|
||||||
|
Contract saved = contractRepository.save(contract);
|
||||||
|
|
||||||
|
PropertyStatus rentedStatus = propertyStatusRepository.findByName("ALQUILADO")
|
||||||
|
.orElseThrow(() -> new BadRequestException("Estado ALQUILADO no encontrado"));
|
||||||
|
property.setStatus(rentedStatus);
|
||||||
|
propertyRepository.save(property);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Contract update(Long id, Contract updated) {
|
||||||
|
Contract contract = findById(id);
|
||||||
|
|
||||||
|
LocalDate newStart = updated.getStartDate() != null ? updated.getStartDate() : contract.getStartDate();
|
||||||
|
LocalDate newEnd = updated.getEndDate();
|
||||||
|
validateNoOverlap(newStart, newEnd, contract.getProperty().getId(), id);
|
||||||
|
|
||||||
|
if (updated.getStartDate() != null) contract.setStartDate(updated.getStartDate());
|
||||||
|
contract.setEndDate(updated.getEndDate());
|
||||||
|
contract.setRentalAmount(updated.getRentalAmount());
|
||||||
|
contract.setDepositAmount(updated.getDepositAmount());
|
||||||
|
contract.setPaymentDay(updated.getPaymentDay());
|
||||||
|
contract.setIbanCharge(updated.getIbanCharge());
|
||||||
|
contract.setNotes(updated.getNotes());
|
||||||
|
if (updated.getStatus() != null) contract.setStatus(updated.getStatus());
|
||||||
|
if (updated.getPaymentPeriod() != null) contract.setPaymentPeriod(updated.getPaymentPeriod());
|
||||||
|
|
||||||
|
if (updated.getContractTenants() != null && !updated.getContractTenants().isEmpty()) {
|
||||||
|
contractTenantRepository.deleteByContractId(contract.getId());
|
||||||
|
List<ContractTenant> resolved = new ArrayList<>();
|
||||||
|
for (ContractTenant ct : updated.getContractTenants()) {
|
||||||
|
Tenant tenant = tenantRepository.findById(ct.getTenant().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Arrendatario", ct.getTenant().getId()));
|
||||||
|
ContractTenant newCt = new ContractTenant();
|
||||||
|
newCt.setContract(contract);
|
||||||
|
newCt.setTenant(tenant);
|
||||||
|
newCt.setRole(ct.getRole());
|
||||||
|
resolved.add(newCt);
|
||||||
|
}
|
||||||
|
contract.setContractTenants(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contractRepository.save(contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Contract terminate(Long id, String cause) {
|
||||||
|
Contract contract = findById(id);
|
||||||
|
contract.setTerminatedAt(java.time.LocalDate.now());
|
||||||
|
contract.setTerminationCause(cause);
|
||||||
|
|
||||||
|
ContractStatus terminatedStatus = new ContractStatus();
|
||||||
|
terminatedStatus.setId(4);
|
||||||
|
contract.setStatus(terminatedStatus);
|
||||||
|
|
||||||
|
PropertyStatus vacantStatus = propertyStatusRepository.findByName("VACIO")
|
||||||
|
.orElseThrow(() -> new BadRequestException("Estado VACIO no encontrado"));
|
||||||
|
contract.getProperty().setStatus(vacantStatus);
|
||||||
|
propertyRepository.save(contract.getProperty());
|
||||||
|
|
||||||
|
return contractRepository.save(contract);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "contract_statuses")
|
||||||
|
public class ContractStatus {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.sapolar.tenant.Tenant;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "contract_tenants", uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"contract_id", "tenant_id"})
|
||||||
|
})
|
||||||
|
public class ContractTenant {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "contract_id", nullable = false)
|
||||||
|
@JsonIgnore
|
||||||
|
private Contract contract;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "tenant_id", nullable = false)
|
||||||
|
private Tenant tenant;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String role = "TITULAR";
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ContractTenantRepository extends JpaRepository<ContractTenant, Long> {
|
||||||
|
List<ContractTenant> findByContractId(Long contractId);
|
||||||
|
List<ContractTenant> findByTenantId(Long tenantId);
|
||||||
|
void deleteByContractId(Long contractId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "expense_repercussions")
|
||||||
|
public class ExpenseRepercussion {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "contract_id", nullable = false)
|
||||||
|
private Contract contract;
|
||||||
|
|
||||||
|
@Column(name = "expense_type", nullable = false, length = 50)
|
||||||
|
private String expenseType;
|
||||||
|
|
||||||
|
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "billing_period", length = 30)
|
||||||
|
private String billingPeriod;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String observations;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/expense-repercussions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseRepercussionController {
|
||||||
|
|
||||||
|
private final ExpenseRepercussionService expenseRepercussionService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<ExpenseRepercussion>>> findByContract(
|
||||||
|
@RequestParam Long contractId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
expenseRepercussionService.findByContract(contractId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseRepercussion>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
expenseRepercussionService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseRepercussion>> create(
|
||||||
|
@RequestParam Long contractId,
|
||||||
|
@RequestBody ExpenseRepercussion repercussion) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto creada",
|
||||||
|
expenseRepercussionService.create(repercussion, contractId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseRepercussion>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody ExpenseRepercussion repercussion) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto actualizada",
|
||||||
|
expenseRepercussionService.update(id, repercussion)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
expenseRepercussionService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Repercusión de gasto eliminada", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ExpenseRepercussionRepository extends JpaRepository<ExpenseRepercussion, Long> {
|
||||||
|
List<ExpenseRepercussion> findByContractId(Long contractId);
|
||||||
|
List<ExpenseRepercussion> findByContractIdAndActiveTrue(Long contractId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseRepercussionService {
|
||||||
|
|
||||||
|
private final ExpenseRepercussionRepository expenseRepercussionRepository;
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
|
||||||
|
public List<ExpenseRepercussion> findByContract(Long contractId) {
|
||||||
|
return expenseRepercussionRepository.findByContractIdAndActiveTrue(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpenseRepercussion findById(Long id) {
|
||||||
|
return expenseRepercussionRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Repercusión de gasto", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseRepercussion create(ExpenseRepercussion repercussion, Long contractId) {
|
||||||
|
if (repercussion.getExpenseType() == null || repercussion.getExpenseType().isBlank()) {
|
||||||
|
throw new BadRequestException("El tipo de gasto es obligatorio");
|
||||||
|
}
|
||||||
|
if (repercussion.getAmount() == null) {
|
||||||
|
throw new BadRequestException("El importe es obligatorio");
|
||||||
|
}
|
||||||
|
|
||||||
|
Contract contract = contractRepository.findById(contractId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Contrato", contractId));
|
||||||
|
repercussion.setContract(contract);
|
||||||
|
|
||||||
|
return expenseRepercussionRepository.save(repercussion);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseRepercussion update(Long id, ExpenseRepercussion updated) {
|
||||||
|
ExpenseRepercussion existing = findById(id);
|
||||||
|
|
||||||
|
if (updated.getExpenseType() != null) {
|
||||||
|
existing.setExpenseType(updated.getExpenseType());
|
||||||
|
}
|
||||||
|
if (updated.getAmount() != null) {
|
||||||
|
existing.setAmount(updated.getAmount());
|
||||||
|
}
|
||||||
|
existing.setBillingPeriod(updated.getBillingPeriod());
|
||||||
|
existing.setObservations(updated.getObservations());
|
||||||
|
|
||||||
|
return expenseRepercussionRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ExpenseRepercussion repercussion = findById(id);
|
||||||
|
repercussion.setActive(false);
|
||||||
|
expenseRepercussionRepository.save(repercussion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "payment_periods")
|
||||||
|
public class PaymentPeriod {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.sapolar.contract;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface PaymentPeriodRepository extends JpaRepository<PaymentPeriod, Integer> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.sapolar.dashboard;
|
||||||
|
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/dashboard")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
private final DashboardService dashboardService;
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getSummary() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(dashboardService.getSummary()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/income-expense")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getMonthlyIncomeExpense(
|
||||||
|
@RequestParam(required = false) Integer year) {
|
||||||
|
if (year == null) {
|
||||||
|
year = java.time.LocalDate.now().getYear();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(dashboardService.getMonthlyIncomeExpense(year)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.sapolar.dashboard;
|
||||||
|
|
||||||
|
import com.sapolar.contract.Contract;
|
||||||
|
import com.sapolar.contract.ContractRepository;
|
||||||
|
import com.sapolar.finance.expense.ExpenseReceipt;
|
||||||
|
import com.sapolar.finance.expense.ExpenseReceiptRepository;
|
||||||
|
import com.sapolar.finance.income.IncomeReceipt;
|
||||||
|
import com.sapolar.finance.income.IncomeReceiptRepository;
|
||||||
|
import com.sapolar.incident.IncidentRepository;
|
||||||
|
import com.sapolar.maintenance.ScheduledMaintenanceRepository;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.tenant.TenantRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class DashboardService {
|
||||||
|
|
||||||
|
private final IncomeReceiptRepository incomeReceiptRepository;
|
||||||
|
private final ExpenseReceiptRepository expenseReceiptRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final IncidentRepository incidentRepository;
|
||||||
|
private final ScheduledMaintenanceRepository maintenanceRepository;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
|
||||||
|
public Map<String, Object> getSummary() {
|
||||||
|
int year = LocalDate.now().getYear();
|
||||||
|
int month = LocalDate.now().getMonthValue();
|
||||||
|
LocalDate startOfYear = LocalDate.of(year, 1, 1);
|
||||||
|
LocalDate startOfMonth = LocalDate.of(year, month, 1);
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
|
BigDecimal incomeYear = incomeReceiptRepository.sumPaidBetween(startOfYear, today);
|
||||||
|
BigDecimal expenseYear = expenseReceiptRepository.sumPaidBetween(startOfYear, today);
|
||||||
|
BigDecimal incomeMonth = incomeReceiptRepository.sumPaidBetween(startOfMonth, today);
|
||||||
|
BigDecimal expenseMonth = expenseReceiptRepository.sumPaidBetween(startOfMonth, today);
|
||||||
|
|
||||||
|
long pendingIncomes = incomeReceiptRepository.countByStatusId(1);
|
||||||
|
long overdueIncomes = incomeReceiptRepository.countByStatusId(3);
|
||||||
|
long pendingExpenses = expenseReceiptRepository.countByStatusId(1);
|
||||||
|
long overdueExpenses = expenseReceiptRepository.countByStatusId(3);
|
||||||
|
|
||||||
|
Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("totalProperties", propertyRepository.count());
|
||||||
|
summary.put("rentedProperties", propertyRepository.findByStatusId(2).size());
|
||||||
|
summary.put("freeProperties", propertyRepository.findByStatusId(1).size());
|
||||||
|
summary.put("totalTenants", tenantRepository.count());
|
||||||
|
summary.put("activeContracts", contractRepository.findByStatusId(1).size());
|
||||||
|
summary.put("pendingIncomes", pendingIncomes);
|
||||||
|
summary.put("overdueIncomes", overdueIncomes);
|
||||||
|
summary.put("pendingExpenses", pendingExpenses);
|
||||||
|
summary.put("overdueExpenses", overdueExpenses);
|
||||||
|
summary.put("incomeThisYear", incomeYear);
|
||||||
|
summary.put("expenseThisYear", expenseYear);
|
||||||
|
summary.put("balanceThisYear", incomeYear.subtract(expenseYear));
|
||||||
|
summary.put("incomeThisMonth", incomeMonth);
|
||||||
|
summary.put("expenseThisMonth", expenseMonth);
|
||||||
|
summary.put("balanceThisMonth", incomeMonth.subtract(expenseMonth));
|
||||||
|
summary.put("openIncidents", incidentRepository.count() - incidentRepository.countByStatusId(4)); // sin REPARADO
|
||||||
|
summary.put("pendingMaintenance", maintenanceRepository.findByCompletedFalse().size());
|
||||||
|
|
||||||
|
// ── Listas de pendientes (top 8) ──
|
||||||
|
summary.put("pendingIncomeList", buildPendingIncomeList());
|
||||||
|
summary.put("pendingExpenseList", buildPendingExpenseList());
|
||||||
|
|
||||||
|
// ── Contratos próximos a vencer (60 días) ──
|
||||||
|
summary.put("expiringContracts", buildExpiringContracts(today));
|
||||||
|
|
||||||
|
// ── Ingresos / Gastos por categoría ──
|
||||||
|
summary.put("incomeByCategory", buildIncomeByCategory(year));
|
||||||
|
summary.put("expenseByCategory", buildExpenseByCategory(year));
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getMonthlyIncomeExpense(int year) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
for (int m = 1; m <= 12; m++) {
|
||||||
|
LocalDate start = LocalDate.of(year, m, 1);
|
||||||
|
LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
|
||||||
|
|
||||||
|
BigDecimal income = incomeReceiptRepository.sumPaidBetween(start, end);
|
||||||
|
BigDecimal expense = expenseReceiptRepository.sumPaidBetween(start, end);
|
||||||
|
|
||||||
|
Map<String, BigDecimal> monthData = new LinkedHashMap<>();
|
||||||
|
monthData.put("income", income);
|
||||||
|
monthData.put("expense", expense);
|
||||||
|
result.put(java.time.Month.of(m).name(), monthData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Métodos auxiliares
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<Map<String, Object>> buildPendingIncomeList() {
|
||||||
|
List<IncomeReceipt> list = incomeReceiptRepository.findPendingOrderByDueDate();
|
||||||
|
return list.stream().limit(8).map(r -> {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", r.getId());
|
||||||
|
m.put("propertyName", r.getProperty().getName());
|
||||||
|
m.put("tenantName", r.getTenant() != null ? r.getTenant().getFullName() : null);
|
||||||
|
m.put("amount", r.getAmount());
|
||||||
|
m.put("periodLabel", r.getPeriodLabel());
|
||||||
|
m.put("dueDate", r.getDueDate());
|
||||||
|
m.put("status", r.getStatus().getName());
|
||||||
|
if (r.getDueDate() != null) {
|
||||||
|
long days = ChronoUnit.DAYS.between(r.getDueDate(), LocalDate.now());
|
||||||
|
m.put("daysOverdue", Math.max(0, days));
|
||||||
|
} else {
|
||||||
|
m.put("daysOverdue", 0);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> buildPendingExpenseList() {
|
||||||
|
List<ExpenseReceipt> list = expenseReceiptRepository.findPendingOrderByDueDate();
|
||||||
|
return list.stream().limit(8).map(e -> {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", e.getId());
|
||||||
|
m.put("supplierName", e.getSupplierName());
|
||||||
|
m.put("categoryName", e.getCategory() != null ? e.getCategory().getName() : null);
|
||||||
|
m.put("description", e.getDescription());
|
||||||
|
m.put("amount", e.getTotalAmount());
|
||||||
|
m.put("dueDate", e.getDueDate());
|
||||||
|
m.put("status", e.getStatus().getName());
|
||||||
|
m.put("propertyName", e.getProperty() != null ? e.getProperty().getName() : null);
|
||||||
|
if (e.getDueDate() != null) {
|
||||||
|
long days = ChronoUnit.DAYS.between(e.getDueDate(), LocalDate.now());
|
||||||
|
m.put("daysOverdue", Math.max(0, days));
|
||||||
|
} else {
|
||||||
|
m.put("daysOverdue", 0);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> buildExpiringContracts(LocalDate today) {
|
||||||
|
LocalDate endWindow = today.plusDays(60);
|
||||||
|
List<Contract> contracts = contractRepository.findContractsExpiringBetween(today, endWindow);
|
||||||
|
return contracts.stream().map(c -> {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", c.getId());
|
||||||
|
m.put("propertyName", c.getProperty().getName());
|
||||||
|
m.put("contractNumber", c.getContractNumber());
|
||||||
|
m.put("endDate", c.getEndDate());
|
||||||
|
m.put("daysLeft", ChronoUnit.DAYS.between(today, c.getEndDate()));
|
||||||
|
return m;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, BigDecimal> buildIncomeByCategory(int year) {
|
||||||
|
Map<String, BigDecimal> result = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : incomeReceiptRepository.sumByCategoryForYear(year)) {
|
||||||
|
result.put((String) row[0], (BigDecimal) row[1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, BigDecimal> buildExpenseByCategory(int year) {
|
||||||
|
Map<String, BigDecimal> result = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : expenseReceiptRepository.sumByCategoryForYear(year)) {
|
||||||
|
result.put((String) row[0], (BigDecimal) row[1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "documents", indexes = {
|
||||||
|
@Index(name = "idx_documents_type", columnList = "document_type_id")
|
||||||
|
})
|
||||||
|
public class Document {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "document_type_id", nullable = false)
|
||||||
|
private DocumentType documentType;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
@OneToMany(mappedBy = "document", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||||
|
private Set<DocumentEntity> entities = new HashSet<>();
|
||||||
|
|
||||||
|
@Column(name = "original_name", nullable = false, length = 255)
|
||||||
|
private String originalName;
|
||||||
|
|
||||||
|
@Column(name = "stored_name", nullable = false, length = 255)
|
||||||
|
private String storedName;
|
||||||
|
|
||||||
|
@Column(name = "mime_type", length = 100)
|
||||||
|
private String mimeType;
|
||||||
|
|
||||||
|
@Column(name = "file_size")
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "uploaded_by")
|
||||||
|
private User uploadedBy;
|
||||||
|
|
||||||
|
@Column(name = "uploaded_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime uploadedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
uploadedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addEntity(String entityType, Long entityId) {
|
||||||
|
DocumentEntity entity = new DocumentEntity();
|
||||||
|
entity.setDocument(this);
|
||||||
|
entity.setEntityType(entityType);
|
||||||
|
entity.setEntityId(entityId);
|
||||||
|
this.entities.add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeEntity(String entityType, Long entityId) {
|
||||||
|
this.entities.removeIf(e ->
|
||||||
|
e.getEntityType().equals(entityType) && e.getEntityId().equals(entityId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/documents")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DocumentController {
|
||||||
|
|
||||||
|
private final DocumentService documentService;
|
||||||
|
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public ResponseEntity<ApiResponse<Document>> upload(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam("entityType") String entityType,
|
||||||
|
@RequestParam("entityId") Long entityId,
|
||||||
|
@RequestParam("documentTypeId") Integer documentTypeId,
|
||||||
|
@RequestParam(required = false) String description,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
Document doc = documentService.uploadFile(file, entityType, entityId,
|
||||||
|
documentTypeId, description, user.userId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Archivo subido", doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/entity/{entityType}/{entityId}")
|
||||||
|
public ResponseEntity<ApiResponse<List<Document>>> getEntityDocuments(
|
||||||
|
@PathVariable String entityType, @PathVariable Long entityId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
documentService.getDocumentsForEntity(entityType, entityId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<ApiResponse<List<Document>>> searchDocuments(
|
||||||
|
@RequestParam(required = false) String entityType,
|
||||||
|
@RequestParam(required = false) Long entityId,
|
||||||
|
@RequestParam(required = false) Integer documentTypeId,
|
||||||
|
@RequestParam(required = false) String originalName) {
|
||||||
|
// Convert empty strings to null for proper query handling
|
||||||
|
String entityTypeParam = (entityType != null && entityType.isBlank()) ? null : entityType;
|
||||||
|
String originalNameParam = (originalName != null && originalName.isBlank()) ? null : originalName;
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
documentService.searchDocuments(entityTypeParam, entityId, documentTypeId, originalNameParam)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/download")
|
||||||
|
public ResponseEntity<Resource> download(@PathVariable Long id) {
|
||||||
|
DocumentService.DocumentDownloadResult result = documentService.downloadFile(id);
|
||||||
|
String mimeType = result.mimeType();
|
||||||
|
if (mimeType == null) {
|
||||||
|
mimeType = "application/octet-stream";
|
||||||
|
}
|
||||||
|
MediaType mediaType = MediaType.parseMediaType(mimeType);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(mediaType)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
"inline; filename=\"" + result.filename() + "\"")
|
||||||
|
.body(result.resource());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
documentService.deleteDocument(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Documento eliminado", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Associations management
|
||||||
|
@GetMapping("/{id}/entities")
|
||||||
|
public ResponseEntity<ApiResponse<List<DocumentEntity>>> getDocumentEntities(
|
||||||
|
@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
documentService.getDocumentEntities(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/entities")
|
||||||
|
public ResponseEntity<ApiResponse<Document>> addEntity(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestParam String entityType,
|
||||||
|
@RequestParam Long entityId) {
|
||||||
|
Document doc = documentService.addEntityAssociation(id, entityType, entityId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Documento asociado", doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}/entities/{entityType}/{entityId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeEntity(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@PathVariable String entityType,
|
||||||
|
@PathVariable Long entityId) {
|
||||||
|
documentService.removeEntityAssociation(id, entityType, entityId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Asociación eliminada", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/types")
|
||||||
|
public ResponseEntity<ApiResponse<List<DocumentType>>> getDocumentTypes() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(documentService.getDocumentTypes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/types/entity/{entityType}")
|
||||||
|
public ResponseEntity<ApiResponse<List<DocumentTypeForEntityDto>>> getDocumentTypesForEntity(
|
||||||
|
@PathVariable String entityType) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
documentService.getDocumentTypesForEntity(entityType)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "document_entities")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DocumentEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "document_id", nullable = false)
|
||||||
|
@JsonIgnoreProperties({"entities", "documentType", "uploadedBy"})
|
||||||
|
private Document document;
|
||||||
|
|
||||||
|
@Column(name = "entity_type", nullable = false, length = 30)
|
||||||
|
private String entityType;
|
||||||
|
|
||||||
|
@Column(name = "entity_id", nullable = false)
|
||||||
|
private Long entityId;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DocumentEntityRepository extends JpaRepository<DocumentEntity, Long> {
|
||||||
|
Optional<DocumentEntity> findByDocumentIdAndEntityTypeAndEntityId(Long documentId, String entityType, Long entityId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DocumentRepository extends JpaRepository<Document, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT d FROM Document d JOIN d.entities e WHERE e.entityType = :entityType AND e.entityId = :entityId")
|
||||||
|
List<Document> findByEntityTypeAndEntityId(@Param("entityType") String entityType, @Param("entityId") Long entityId);
|
||||||
|
|
||||||
|
List<Document> findByDocumentTypeId(Integer documentTypeId);
|
||||||
|
|
||||||
|
@Query("SELECT d FROM Document d WHERE d.documentType.id = :typeId")
|
||||||
|
List<Document> findByDocumentType(@Param("typeId") Integer typeId);
|
||||||
|
|
||||||
|
@Query("SELECT d FROM Document d JOIN d.entities e WHERE " +
|
||||||
|
"(:entityType IS NULL OR e.entityType = :entityType) AND " +
|
||||||
|
"(:entityId IS NULL OR e.entityId = :entityId) AND " +
|
||||||
|
"(:documentTypeId IS NULL OR d.documentType.id = :documentTypeId) AND " +
|
||||||
|
"(:originalName IS NULL OR LOWER(d.originalName) LIKE LOWER(CONCAT('%', :originalName, '%')))")
|
||||||
|
List<Document> searchDocuments(
|
||||||
|
@Param("entityType") String entityType,
|
||||||
|
@Param("entityId") Long entityId,
|
||||||
|
@Param("documentTypeId") Integer documentTypeId,
|
||||||
|
@Param("originalName") String originalName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.config.FileStorageConfig;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.UrlResource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.MalformedURLException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DocumentService {
|
||||||
|
|
||||||
|
private final FileStorageConfig fileStorageConfig;
|
||||||
|
private final DocumentRepository documentRepository;
|
||||||
|
private final DocumentEntityRepository documentEntityRepository;
|
||||||
|
private final DocumentTypeRepository documentTypeRepository;
|
||||||
|
private final DocumentTypeEntityAllowedRepository documentTypeEntityAllowedRepository;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Document uploadFile(MultipartFile file, String entityType, Long entityId,
|
||||||
|
Integer documentTypeId, String description, Long uploaderId) {
|
||||||
|
String originalName = StringUtils.cleanPath(file.getOriginalFilename());
|
||||||
|
if (originalName.isBlank()) {
|
||||||
|
throw new BadRequestException("Nombre de archivo no válido");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que el tipo de documento está permitido para esta entidad
|
||||||
|
if (!isDocumentTypeAllowedForEntity(documentTypeId, entityType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El tipo de documento no está permitido para entidades de tipo " + entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String extension = "";
|
||||||
|
int dotIndex = originalName.lastIndexOf('.');
|
||||||
|
if (dotIndex > 0) {
|
||||||
|
extension = originalName.substring(dotIndex);
|
||||||
|
}
|
||||||
|
String storedName = java.util.UUID.randomUUID().toString() + extension;
|
||||||
|
|
||||||
|
// Guardar archivo en disco (usando la primera entidad para la ruta)
|
||||||
|
Path uploadDir = Paths.get(fileStorageConfig.getPath())
|
||||||
|
.resolve(entityType.toLowerCase())
|
||||||
|
.resolve(entityId.toString());
|
||||||
|
try {
|
||||||
|
Files.createDirectories(uploadDir);
|
||||||
|
Path targetPath = uploadDir.resolve(storedName);
|
||||||
|
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Error al subir el archivo: " + originalName, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear documento
|
||||||
|
Document document = new Document();
|
||||||
|
DocumentType docType = new DocumentType();
|
||||||
|
docType.setId(documentTypeId);
|
||||||
|
document.setDocumentType(docType);
|
||||||
|
document.setOriginalName(originalName);
|
||||||
|
document.setStoredName(storedName);
|
||||||
|
document.setMimeType(file.getContentType());
|
||||||
|
document.setFileSize(file.getSize());
|
||||||
|
document.setDescription(description);
|
||||||
|
|
||||||
|
Document savedDoc = documentRepository.save(document);
|
||||||
|
|
||||||
|
// Crear asociación con la entidad primaria
|
||||||
|
savedDoc.addEntity(entityType, entityId);
|
||||||
|
return documentRepository.save(savedDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida si un tipo de documento está permitido para una entidad.
|
||||||
|
* Si la relación no existe en la tabla de permitidos, se permite por defecto
|
||||||
|
* (comportamiento backward compatible).
|
||||||
|
*/
|
||||||
|
public boolean isDocumentTypeAllowedForEntity(Integer documentTypeId, String entityType) {
|
||||||
|
// Si no existe la tabla de permitidos, permitir (backward compatible)
|
||||||
|
if (!documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType)) {
|
||||||
|
// Verificar si existe alguna configuración para este tipo de documento
|
||||||
|
List<DocumentTypeEntityAllowed> configs = documentTypeEntityAllowedRepository
|
||||||
|
.findByDocumentTypeIdAndEntityType(documentTypeId, entityType)
|
||||||
|
.stream().toList();
|
||||||
|
if (configs.isEmpty()) {
|
||||||
|
// No hay configuración, verificar si hay alguna para este document type
|
||||||
|
long totalConfigs = documentTypeEntityAllowedRepository.findAll().stream()
|
||||||
|
.filter(c -> c.getDocumentType().getId().equals(documentTypeId))
|
||||||
|
.count();
|
||||||
|
// Si no hay ninguna configuración para este tipo de documento, permitir
|
||||||
|
// (es un tipo antiguo sin restricciones)
|
||||||
|
if (totalConfigs == 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Hay configuraciones pero ninguna para esta entidad específica
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return documentTypeEntityAllowedRepository.existsByDocumentTypeIdAndEntityType(documentTypeId, entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DocumentDownloadResult downloadFile(Long documentId) {
|
||||||
|
Document document = documentRepository.findById(documentId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
|
||||||
|
|
||||||
|
// Usar la primera entidad para determinar la ruta del archivo
|
||||||
|
if (document.getEntities().isEmpty()) {
|
||||||
|
throw new ResourceNotFoundException("Documento", documentId);
|
||||||
|
}
|
||||||
|
String entityType = document.getEntities().iterator().next().getEntityType();
|
||||||
|
Long entityId = document.getEntities().iterator().next().getEntityId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path filePath = Paths.get(fileStorageConfig.getPath())
|
||||||
|
.resolve(entityType.toLowerCase())
|
||||||
|
.resolve(entityId.toString())
|
||||||
|
.resolve(document.getStoredName());
|
||||||
|
|
||||||
|
Resource resource = new UrlResource(filePath.toUri());
|
||||||
|
if (resource.exists() && resource.isReadable()) {
|
||||||
|
return new DocumentDownloadResult(resource, document.getMimeType(), document.getOriginalName());
|
||||||
|
}
|
||||||
|
throw new RuntimeException("No se puede leer el archivo: " + document.getOriginalName());
|
||||||
|
} catch (MalformedURLException e) {
|
||||||
|
throw new RuntimeException("Error al acceder al archivo", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Document> getDocumentsForEntity(String entityType, Long entityId) {
|
||||||
|
return documentRepository.findByEntityTypeAndEntityId(entityType, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Document> searchDocuments(String entityType, Long entityId,
|
||||||
|
Integer documentTypeId, String originalName) {
|
||||||
|
return documentRepository.searchDocuments(entityType, entityId, documentTypeId, originalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteDocument(Long documentId) {
|
||||||
|
Document document = documentRepository.findById(documentId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
|
||||||
|
|
||||||
|
// Eliminar archivo físico
|
||||||
|
if (!document.getEntities().isEmpty()) {
|
||||||
|
DocumentEntity firstEntity = document.getEntities().iterator().next();
|
||||||
|
try {
|
||||||
|
Path filePath = Paths.get(fileStorageConfig.getPath())
|
||||||
|
.resolve(firstEntity.getEntityType().toLowerCase())
|
||||||
|
.resolve(firstEntity.getEntityId().toString())
|
||||||
|
.resolve(document.getStoredName());
|
||||||
|
Files.deleteIfExists(filePath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Log error but continue with DB deletion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
documentRepository.delete(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Document addEntityAssociation(Long documentId, String entityType, Long entityId) {
|
||||||
|
Document document = documentRepository.findById(documentId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
|
||||||
|
|
||||||
|
// Validar que el tipo de documento está permitido para esta entidad
|
||||||
|
if (!isDocumentTypeAllowedForEntity(document.getDocumentType().getId(), entityType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El tipo de documento no está permitido para entidades de tipo " + entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que no exista ya
|
||||||
|
if (documentEntityRepository.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId).isPresent()) {
|
||||||
|
throw new BadRequestException("El documento ya está asociado a esta entidad");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEntity(entityType, entityId);
|
||||||
|
return documentRepository.save(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeEntityAssociation(Long documentId, String entityType, Long entityId) {
|
||||||
|
DocumentEntity entity = documentEntityRepository
|
||||||
|
.findByDocumentIdAndEntityTypeAndEntityId(documentId, entityType, entityId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Asociación no encontrada"));
|
||||||
|
|
||||||
|
// Verificar que no sea la última asociación (el documento debe tener al menos una)
|
||||||
|
Document document = entity.getDocument();
|
||||||
|
if (document.getEntities().size() <= 1) {
|
||||||
|
throw new BadRequestException("No se puede eliminar la última asociación. Elimine el documento completo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEntity(entityType, entityId);
|
||||||
|
documentEntityRepository.delete(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DocumentEntity> getDocumentEntities(Long documentId) {
|
||||||
|
Document document = documentRepository.findById(documentId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Documento", documentId));
|
||||||
|
return List.copyOf(document.getEntities());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DocumentType> getDocumentTypes() {
|
||||||
|
return documentTypeRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los tipos de documento permitidos para una entidad específica.
|
||||||
|
* Incluye información sobre si es obligatorio o no.
|
||||||
|
*/
|
||||||
|
public List<DocumentTypeForEntityDto> getDocumentTypesForEntity(String entityType) {
|
||||||
|
List<DocumentTypeEntityAllowed> allowed = documentTypeEntityAllowedRepository
|
||||||
|
.findAllowedTypesForEntity(entityType);
|
||||||
|
|
||||||
|
return allowed.stream()
|
||||||
|
.map(dtea -> new DocumentTypeForEntityDto(
|
||||||
|
dtea.getDocumentType().getId(),
|
||||||
|
dtea.getDocumentType().getName(),
|
||||||
|
dtea.getEntityType(),
|
||||||
|
dtea.getCanUpload(),
|
||||||
|
dtea.getMustHave(),
|
||||||
|
dtea.getDescription()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record para devolver el archivo y su información de cabecera
|
||||||
|
public record DocumentDownloadResult(Resource resource, String mimeType, String filename) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "document_types")
|
||||||
|
public class DocumentType {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "document_type_entity_allowed", indexes = {
|
||||||
|
@Index(name = "idx_dtea_entity", columnList = "entity_type")
|
||||||
|
})
|
||||||
|
public class DocumentTypeEntityAllowed {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "document_type_id", nullable = false)
|
||||||
|
private DocumentType documentType;
|
||||||
|
|
||||||
|
@Column(name = "entity_type", nullable = false, length = 30)
|
||||||
|
private String entityType;
|
||||||
|
|
||||||
|
@Column(name = "can_upload", nullable = false)
|
||||||
|
private Boolean canUpload = true;
|
||||||
|
|
||||||
|
@Column(name = "must_have", nullable = false)
|
||||||
|
private Boolean mustHave = false;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DocumentTypeEntityAllowedRepository extends JpaRepository<DocumentTypeEntityAllowed, Integer> {
|
||||||
|
|
||||||
|
List<DocumentTypeEntityAllowed> findByEntityType(String entityType);
|
||||||
|
|
||||||
|
List<DocumentTypeEntityAllowed> findByEntityTypeAndCanUploadTrue(String entityType);
|
||||||
|
|
||||||
|
Optional<DocumentTypeEntityAllowed> findByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType);
|
||||||
|
|
||||||
|
boolean existsByDocumentTypeIdAndEntityType(Integer documentTypeId, String entityType);
|
||||||
|
|
||||||
|
@Query("SELECT dtea FROM DocumentTypeEntityAllowed dtea " +
|
||||||
|
"JOIN FETCH dtea.documentType dt " +
|
||||||
|
"WHERE dtea.entityType = :entityType AND dtea.canUpload = true " +
|
||||||
|
"ORDER BY dt.name")
|
||||||
|
List<DocumentTypeEntityAllowed> findAllowedTypesForEntity(@Param("entityType") String entityType);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class DocumentTypeForEntityDto {
|
||||||
|
private Integer documentTypeId;
|
||||||
|
private String documentTypeName;
|
||||||
|
private String entityType;
|
||||||
|
private Boolean canUpload;
|
||||||
|
private Boolean mustHave;
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.sapolar.document;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DocumentTypeRepository extends JpaRepository<DocumentType, Integer> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.sapolar.finance.bank;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "bank_accounts")
|
||||||
|
public class BankAccount {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String holder;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 34)
|
||||||
|
private String iban;
|
||||||
|
|
||||||
|
@Column(name = "bank_name", length = 100)
|
||||||
|
private String bankName;
|
||||||
|
|
||||||
|
@Column(name = "swift_bic", length = 11)
|
||||||
|
private String swiftBic;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 3)
|
||||||
|
private String currency = "EUR";
|
||||||
|
|
||||||
|
@Column(name = "is_default", nullable = false)
|
||||||
|
private Boolean isDefault = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.sapolar.finance.bank;
|
||||||
|
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/bank-accounts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BankAccountController {
|
||||||
|
|
||||||
|
private final BankAccountService bankAccountService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<BankAccount>>> findAll(
|
||||||
|
@RequestParam(defaultValue = "false") boolean all) {
|
||||||
|
List<BankAccount> list = all ? bankAccountService.findAll()
|
||||||
|
: bankAccountService.findAllActive();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<BankAccount>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(bankAccountService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
|
||||||
|
public ResponseEntity<ApiResponse<BankAccount>> create(@RequestBody BankAccount account) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria creada",
|
||||||
|
bankAccountService.create(account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
|
||||||
|
public ResponseEntity<ApiResponse<BankAccount>> update(@PathVariable Long id,
|
||||||
|
@RequestBody BankAccount account) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria actualizada",
|
||||||
|
bankAccountService.update(id, account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE')")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
bankAccountService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Cuenta bancaria desactivada", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.sapolar.finance.bank;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
|
||||||
|
List<BankAccount> findByActiveTrueOrderByName();
|
||||||
|
Optional<BankAccount> findByIsDefaultTrueAndActiveTrue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.sapolar.finance.bank;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BankAccountService {
|
||||||
|
|
||||||
|
private final BankAccountRepository repository;
|
||||||
|
|
||||||
|
public List<BankAccount> findAllActive() {
|
||||||
|
return repository.findByActiveTrueOrderByName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BankAccount> findAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BankAccount findById(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BankAccount create(BankAccount account) {
|
||||||
|
if (account.getIsDefault()) {
|
||||||
|
clearDefaultFlag();
|
||||||
|
}
|
||||||
|
return repository.save(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BankAccount update(Long id, BankAccount updated) {
|
||||||
|
BankAccount account = findById(id);
|
||||||
|
account.setName(updated.getName());
|
||||||
|
account.setHolder(updated.getHolder());
|
||||||
|
account.setIban(updated.getIban());
|
||||||
|
account.setBankName(updated.getBankName());
|
||||||
|
account.setSwiftBic(updated.getSwiftBic());
|
||||||
|
account.setCurrency(updated.getCurrency());
|
||||||
|
account.setDescription(updated.getDescription());
|
||||||
|
|
||||||
|
if (updated.getIsDefault() && !Boolean.TRUE.equals(account.getIsDefault())) {
|
||||||
|
clearDefaultFlag();
|
||||||
|
account.setIsDefault(true);
|
||||||
|
} else if (!Boolean.TRUE.equals(updated.getIsDefault())) {
|
||||||
|
account.setIsDefault(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.save(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
BankAccount account = findById(id);
|
||||||
|
account.setActive(false);
|
||||||
|
repository.save(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearDefaultFlag() {
|
||||||
|
repository.findByIsDefaultTrueAndActiveTrue()
|
||||||
|
.ifPresent(a -> {
|
||||||
|
a.setIsDefault(false);
|
||||||
|
repository.save(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "expense_categories")
|
||||||
|
public class ExpenseCategory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ExpenseCategoryRepository extends JpaRepository<ExpenseCategory, Long> {
|
||||||
|
List<ExpenseCategory> findByActiveTrueOrderByName();
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "expense_receipts", indexes = {
|
||||||
|
@Index(name = "idx_expense_receipts_template", columnList = "template_id"),
|
||||||
|
@Index(name = "idx_expense_receipts_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_expense_receipts_property_group", columnList = "property_group_id"),
|
||||||
|
@Index(name = "idx_expense_receipts_category", columnList = "category_id"),
|
||||||
|
@Index(name = "idx_expense_receipts_status", columnList = "status_id"),
|
||||||
|
@Index(name = "idx_expense_receipts_issue_date", columnList = "issue_date")
|
||||||
|
})
|
||||||
|
public class ExpenseReceipt {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "template_id")
|
||||||
|
private ExpenseTemplate template;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id")
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_group_id")
|
||||||
|
private PropertyGroup propertyGroup;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "bank_account_id")
|
||||||
|
private BankAccount bankAccount;
|
||||||
|
|
||||||
|
@Column(name = "is_domiciled", nullable = false)
|
||||||
|
private Boolean isDomiciled = false;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "category_id")
|
||||||
|
private ExpenseCategory category;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "status_id", nullable = false)
|
||||||
|
private ExpenseStatus status;
|
||||||
|
|
||||||
|
@Column(name = "supplier_name", length = 200)
|
||||||
|
private String supplierName;
|
||||||
|
|
||||||
|
@Column(name = "supplier_fiscal_id", length = 20)
|
||||||
|
private String supplierFiscalId;
|
||||||
|
|
||||||
|
@Column(name = "invoice_number", length = 50)
|
||||||
|
private String invoiceNumber;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 12, scale = 2)
|
||||||
|
private BigDecimal amount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "tax_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal taxAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "total_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal totalAmount;
|
||||||
|
|
||||||
|
@Column(name = "is_variable", nullable = false)
|
||||||
|
private Boolean isVariable = false;
|
||||||
|
|
||||||
|
@Column(name = "previous_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal previousAmount;
|
||||||
|
|
||||||
|
@Column(name = "issue_date", nullable = false)
|
||||||
|
private LocalDate issueDate;
|
||||||
|
|
||||||
|
@Column(name = "due_date")
|
||||||
|
private LocalDate dueDate;
|
||||||
|
|
||||||
|
@Column(name = "payment_date")
|
||||||
|
private LocalDate paymentDate;
|
||||||
|
|
||||||
|
@Column(name = "payment_method", length = 30)
|
||||||
|
private String paymentMethod;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private User createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.common.dto.PagedResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/expense-receipts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseReceiptController {
|
||||||
|
|
||||||
|
private final ExpenseReceiptService expenseReceiptService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<PagedResponse<ExpenseReceipt>>> findAll(
|
||||||
|
@RequestParam(required = false) Long propertyId,
|
||||||
|
@RequestParam(required = false) Long templateId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "id") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String dir) {
|
||||||
|
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
|
||||||
|
Pageable pageable = PageRequest.of(page, size, sorting);
|
||||||
|
if (propertyId != null) {
|
||||||
|
List<ExpenseReceipt> list = expenseReceiptService.findByProperty(propertyId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
if (templateId != null) {
|
||||||
|
List<ExpenseReceipt> list = expenseReceiptService.findByTemplate(templateId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(expenseReceiptService.findAll(pageable))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public ResponseEntity<ApiResponse<List<ExpenseReceipt>>> getPending() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPending()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending/count")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Long>>> getPendingCount() {
|
||||||
|
long total = expenseReceiptService.countPending();
|
||||||
|
long variable = expenseReceiptService.findPendingVariable().size();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(Map.of("total", total, "variable", variable)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending/variable")
|
||||||
|
public ResponseEntity<ApiResponse<List<ExpenseReceipt>>> getPendingVariable() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findPendingVariable()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseReceipt>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseReceiptService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseReceipt>> createManual(@Valid @RequestBody ExpenseReceipt receipt,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
ExpenseReceipt data = receipt;
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto creado",
|
||||||
|
expenseReceiptService.createManual(data, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseReceipt>> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody ExpenseReceipt receipt) {
|
||||||
|
ExpenseReceipt data = receipt;
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto actualizado",
|
||||||
|
expenseReceiptService.update(id, data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/pay")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseReceipt>> registerPayment(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String paymentDateStr = body.get("paymentDate");
|
||||||
|
if (paymentDateStr == null) {
|
||||||
|
paymentDateStr = LocalDate.now().toString();
|
||||||
|
}
|
||||||
|
String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA");
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Pago registrado",
|
||||||
|
expenseReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/amount")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseReceipt>> updateAmount(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
BigDecimal amount = body.containsKey("amount")
|
||||||
|
? new BigDecimal(body.get("amount").toString())
|
||||||
|
: BigDecimal.ZERO;
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Importe actualizado",
|
||||||
|
expenseReceiptService.updateAmount(id, amount)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
expenseReceiptService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de gasto eliminado", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ExpenseReceiptRepository extends JpaRepository<ExpenseReceipt, Long> {
|
||||||
|
List<ExpenseReceipt> findByTemplateId(Long templateId);
|
||||||
|
|
||||||
|
List<ExpenseReceipt> findByPropertyId(Long propertyId);
|
||||||
|
|
||||||
|
List<ExpenseReceipt> findByStatusId(Integer statusId);
|
||||||
|
|
||||||
|
List<ExpenseReceipt> findByTemplateIdAndIssueDateBetween(Long templateId, LocalDate start, LocalDate end);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.property.id = :propertyId " +
|
||||||
|
"AND e.status.name = 'PAGADO' AND e.issueDate BETWEEN :start AND :end")
|
||||||
|
BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e WHERE e.status.name = 'PAGADO' " +
|
||||||
|
"AND e.issueDate BETWEEN :start AND :end")
|
||||||
|
BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||||
|
|
||||||
|
long countByStatusId(Integer statusId);
|
||||||
|
|
||||||
|
Optional<ExpenseReceipt> findTopByTemplateIdOrderByIssueDateDesc(Long templateId);
|
||||||
|
|
||||||
|
@Query("SELECT e FROM ExpenseReceipt e JOIN FETCH e.category " +
|
||||||
|
"WHERE e.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY e.dueDate ASC")
|
||||||
|
List<ExpenseReceipt> findPendingOrderByDueDate();
|
||||||
|
|
||||||
|
@Query("SELECT c.name, COALESCE(SUM(e.totalAmount), 0) FROM ExpenseReceipt e JOIN e.category c " +
|
||||||
|
"WHERE YEAR(e.issueDate) = :year AND e.status.name = 'PAGADO' " +
|
||||||
|
"GROUP BY c.name ORDER BY SUM(e.totalAmount) DESC")
|
||||||
|
List<Object[]> sumByCategoryForYear(@Param("year") int year);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.finance.bank.BankAccountRepository;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.property.PropertyGroupRepository;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseReceiptService {
|
||||||
|
|
||||||
|
private final ExpenseReceiptRepository expenseReceiptRepository;
|
||||||
|
private final ExpenseTemplateRepository expenseTemplateRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final PropertyGroupRepository propertyGroupRepository;
|
||||||
|
private final BankAccountRepository bankAccountRepository;
|
||||||
|
private final ExpenseCategoryRepository expenseCategoryRepository;
|
||||||
|
|
||||||
|
public List<ExpenseReceipt> findAll() {
|
||||||
|
return expenseReceiptRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<ExpenseReceipt> findAll(Pageable pageable) {
|
||||||
|
return expenseReceiptRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpenseReceipt findById(Long id) {
|
||||||
|
return expenseReceiptRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Recibo de gasto", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExpenseReceipt> findByProperty(Long propertyId) {
|
||||||
|
return expenseReceiptRepository.findByPropertyId(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExpenseReceipt> findByTemplate(Long templateId) {
|
||||||
|
return expenseReceiptRepository.findByTemplateId(templateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExpenseReceipt> findPending() {
|
||||||
|
return expenseReceiptRepository.findByStatusId(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countPending() {
|
||||||
|
return expenseReceiptRepository.countByStatusId(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExpenseReceipt> findPendingVariable() {
|
||||||
|
return expenseReceiptRepository.findAll().stream()
|
||||||
|
.filter(r -> Boolean.TRUE.equals(r.getIsVariable()))
|
||||||
|
.filter(r -> r.getAmount() == null || r.getAmount().compareTo(BigDecimal.ZERO) == 0)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseReceipt createFromTemplate(ExpenseTemplate template, LocalDate issueDate, Long userId) {
|
||||||
|
ExpenseReceipt receipt = new ExpenseReceipt();
|
||||||
|
|
||||||
|
receipt.setTemplate(template);
|
||||||
|
receipt.setProperty(template.getProperty());
|
||||||
|
receipt.setPropertyGroup(template.getPropertyGroup());
|
||||||
|
receipt.setBankAccount(template.getBankAccount());
|
||||||
|
receipt.setIsDomiciled(template.getIsDomiciled());
|
||||||
|
receipt.setCategory(template.getCategory());
|
||||||
|
receipt.setSupplierName(template.getSupplierName());
|
||||||
|
receipt.setSupplierFiscalId(template.getSupplierFiscalId());
|
||||||
|
receipt.setDescription(template.getDescription());
|
||||||
|
receipt.setNotes(template.getNotes());
|
||||||
|
receipt.setIssueDate(issueDate);
|
||||||
|
receipt.setDueDate(issueDate.plusDays(30));
|
||||||
|
|
||||||
|
if (Boolean.TRUE.equals(template.getIsVariable())) {
|
||||||
|
receipt.setIsVariable(true);
|
||||||
|
receipt.setAmount(BigDecimal.ZERO);
|
||||||
|
receipt.setTaxAmount(BigDecimal.ZERO);
|
||||||
|
receipt.setTotalAmount(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
expenseReceiptRepository.findTopByTemplateIdOrderByIssueDateDesc(template.getId())
|
||||||
|
.ifPresent(last -> receipt.setPreviousAmount(last.getAmount()));
|
||||||
|
} else {
|
||||||
|
receipt.setIsVariable(false);
|
||||||
|
receipt.setAmount(template.getAmount());
|
||||||
|
receipt.setTaxAmount(template.getTaxAmount() != null ? template.getTaxAmount() : BigDecimal.ZERO);
|
||||||
|
receipt.setTotalAmount(receipt.getAmount().add(receipt.getTaxAmount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpenseStatus status = new ExpenseStatus();
|
||||||
|
status.setId(1);
|
||||||
|
receipt.setStatus(status);
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
receipt.setCreatedBy(user);
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseReceipt createManual(ExpenseReceipt receipt, Long userId) {
|
||||||
|
if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new BadRequestException("El importe debe ser mayor que cero");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getProperty() != null && receipt.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(receipt.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId()));
|
||||||
|
receipt.setProperty(property);
|
||||||
|
}
|
||||||
|
if (receipt.getPropertyGroup() != null && receipt.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(receipt.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", receipt.getPropertyGroup().getId()));
|
||||||
|
receipt.setPropertyGroup(group);
|
||||||
|
}
|
||||||
|
if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId()));
|
||||||
|
receipt.setBankAccount(bank);
|
||||||
|
}
|
||||||
|
if (receipt.getCategory() != null && receipt.getCategory().getId() != null) {
|
||||||
|
ExpenseCategory category = expenseCategoryRepository.findById(receipt.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", receipt.getCategory().getId()));
|
||||||
|
receipt.setCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getTotalAmount() == null) {
|
||||||
|
BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO;
|
||||||
|
receipt.setTotalAmount(receipt.getAmount().add(tax));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getStatus() == null) {
|
||||||
|
ExpenseStatus status = new ExpenseStatus();
|
||||||
|
status.setId(1);
|
||||||
|
receipt.setStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
receipt.setCreatedBy(user);
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseReceipt update(Long id, ExpenseReceipt updated) {
|
||||||
|
ExpenseReceipt receipt = findById(id);
|
||||||
|
|
||||||
|
if (updated.getTemplate() != null && updated.getTemplate().getId() != null) {
|
||||||
|
ExpenseTemplate template = expenseTemplateRepository.findById(updated.getTemplate().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", updated.getTemplate().getId()));
|
||||||
|
receipt.setTemplate(template);
|
||||||
|
} else {
|
||||||
|
receipt.setTemplate(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(updated.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
|
||||||
|
receipt.setProperty(property);
|
||||||
|
} else {
|
||||||
|
receipt.setProperty(null);
|
||||||
|
}
|
||||||
|
if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId()));
|
||||||
|
receipt.setPropertyGroup(group);
|
||||||
|
} else {
|
||||||
|
receipt.setPropertyGroup(null);
|
||||||
|
}
|
||||||
|
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
|
||||||
|
receipt.setBankAccount(bank);
|
||||||
|
} else {
|
||||||
|
receipt.setBankAccount(null);
|
||||||
|
}
|
||||||
|
receipt.setIsDomiciled(updated.getIsDomiciled());
|
||||||
|
|
||||||
|
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
|
||||||
|
ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId()));
|
||||||
|
receipt.setCategory(category);
|
||||||
|
} else {
|
||||||
|
receipt.setCategory(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getStatus() != null && updated.getStatus().getId() != null) {
|
||||||
|
ExpenseStatus status = new ExpenseStatus();
|
||||||
|
status.setId(updated.getStatus().getId());
|
||||||
|
receipt.setStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt.setSupplierName(updated.getSupplierName());
|
||||||
|
receipt.setSupplierFiscalId(updated.getSupplierFiscalId());
|
||||||
|
receipt.setInvoiceNumber(updated.getInvoiceNumber());
|
||||||
|
receipt.setAmount(updated.getAmount());
|
||||||
|
receipt.setTaxAmount(updated.getTaxAmount());
|
||||||
|
receipt.setTotalAmount(updated.getTotalAmount());
|
||||||
|
receipt.setIsVariable(updated.getIsVariable());
|
||||||
|
receipt.setPreviousAmount(updated.getPreviousAmount());
|
||||||
|
receipt.setIssueDate(updated.getIssueDate());
|
||||||
|
receipt.setDueDate(updated.getDueDate());
|
||||||
|
receipt.setPaymentDate(updated.getPaymentDate());
|
||||||
|
receipt.setPaymentMethod(updated.getPaymentMethod());
|
||||||
|
receipt.setDescription(updated.getDescription());
|
||||||
|
receipt.setNotes(updated.getNotes());
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseReceipt registerPayment(Long id, String paymentDate, String paymentMethod) {
|
||||||
|
ExpenseReceipt receipt = findById(id);
|
||||||
|
receipt.setPaymentDate(LocalDate.parse(paymentDate));
|
||||||
|
receipt.setPaymentMethod(paymentMethod);
|
||||||
|
|
||||||
|
ExpenseStatus status = new ExpenseStatus();
|
||||||
|
status.setId(2);
|
||||||
|
receipt.setStatus(status);
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseReceipt updateAmount(Long id, BigDecimal amount) {
|
||||||
|
ExpenseReceipt receipt = findById(id);
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(receipt.getIsVariable())) {
|
||||||
|
throw new BadRequestException("Solo se puede actualizar el importe de recibos variables");
|
||||||
|
}
|
||||||
|
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new BadRequestException("El importe debe ser mayor que cero");
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt.setAmount(amount);
|
||||||
|
BigDecimal tax = receipt.getTaxAmount() != null ? receipt.getTaxAmount() : BigDecimal.ZERO;
|
||||||
|
receipt.setTotalAmount(amount.add(tax));
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ExpenseReceipt receipt = findById(id);
|
||||||
|
expenseReceiptRepository.delete(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "expense_statuses")
|
||||||
|
public class ExpenseStatus {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.contract.PaymentPeriod;
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "expense_templates", indexes = {
|
||||||
|
@Index(name = "idx_expense_templates_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_expense_templates_property_group", columnList = "property_group_id"),
|
||||||
|
@Index(name = "idx_expense_templates_category", columnList = "category_id"),
|
||||||
|
@Index(name = "idx_expense_templates_period", columnList = "period_id")
|
||||||
|
})
|
||||||
|
public class ExpenseTemplate {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id")
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_group_id")
|
||||||
|
private PropertyGroup propertyGroup;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "bank_account_id")
|
||||||
|
private BankAccount bankAccount;
|
||||||
|
|
||||||
|
@Column(name = "is_domiciled", nullable = false)
|
||||||
|
private Boolean isDomiciled = false;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "category_id")
|
||||||
|
private ExpenseCategory category;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "period_id", nullable = false)
|
||||||
|
private PaymentPeriod period;
|
||||||
|
|
||||||
|
@Column(name = "payment_day", nullable = false)
|
||||||
|
private Integer paymentDay;
|
||||||
|
|
||||||
|
@Column(name = "supplier_name", length = 200)
|
||||||
|
private String supplierName;
|
||||||
|
|
||||||
|
@Column(name = "supplier_fiscal_id", length = 20)
|
||||||
|
private String supplierFiscalId;
|
||||||
|
|
||||||
|
@Column(precision = 12, scale = 2)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "tax_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal taxAmount = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "is_variable", nullable = false)
|
||||||
|
private Boolean isVariable = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private User createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.common.dto.PagedResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/expense-templates")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseTemplateController {
|
||||||
|
|
||||||
|
private final ExpenseTemplateService expenseTemplateService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<PagedResponse<ExpenseTemplate>>> findAll(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "id") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String dir) {
|
||||||
|
List<ExpenseTemplate> list = expenseTemplateService.findAll();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/active")
|
||||||
|
public ResponseEntity<ApiResponse<List<ExpenseTemplate>>> findActive() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findActive()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseTemplate>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseTemplateService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseTemplate>> create(@Valid @RequestBody ExpenseTemplate template,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto creada",
|
||||||
|
expenseTemplateService.create(template, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseTemplate>> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody ExpenseTemplate template) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto actualizada",
|
||||||
|
expenseTemplateService.update(id, template)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/toggle-active")
|
||||||
|
public ResponseEntity<ApiResponse<ExpenseTemplate>> toggleActive(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Estado de plantilla cambiado",
|
||||||
|
expenseTemplateService.toggleActive(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
expenseTemplateService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Plantilla de gasto eliminada", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ExpenseTemplateRepository extends JpaRepository<ExpenseTemplate, Long> {
|
||||||
|
List<ExpenseTemplate> findByActiveTrue();
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.sapolar.finance.expense;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.contract.PaymentPeriod;
|
||||||
|
import com.sapolar.contract.PaymentPeriodRepository;
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.finance.bank.BankAccountRepository;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.property.PropertyGroupRepository;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExpenseTemplateService {
|
||||||
|
|
||||||
|
private final ExpenseTemplateRepository expenseTemplateRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final PropertyGroupRepository propertyGroupRepository;
|
||||||
|
private final BankAccountRepository bankAccountRepository;
|
||||||
|
private final ExpenseCategoryRepository expenseCategoryRepository;
|
||||||
|
private final PaymentPeriodRepository paymentPeriodRepository;
|
||||||
|
|
||||||
|
public List<ExpenseTemplate> findAll() {
|
||||||
|
return expenseTemplateRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpenseTemplate findById(Long id) {
|
||||||
|
return expenseTemplateRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Plantilla de gasto", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<ExpenseTemplate> findAll(Pageable pageable) {
|
||||||
|
return expenseTemplateRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExpenseTemplate> findActive() {
|
||||||
|
return expenseTemplateRepository.findByActiveTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseTemplate create(ExpenseTemplate template, Long userId) {
|
||||||
|
if (template.getProperty() != null && template.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(template.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", template.getProperty().getId()));
|
||||||
|
template.setProperty(property);
|
||||||
|
}
|
||||||
|
if (template.getPropertyGroup() != null && template.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(template.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", template.getPropertyGroup().getId()));
|
||||||
|
template.setPropertyGroup(group);
|
||||||
|
}
|
||||||
|
if (template.getBankAccount() != null && template.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(template.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", template.getBankAccount().getId()));
|
||||||
|
template.setBankAccount(bank);
|
||||||
|
}
|
||||||
|
if (template.getCategory() != null && template.getCategory().getId() != null) {
|
||||||
|
ExpenseCategory category = expenseCategoryRepository.findById(template.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", template.getCategory().getId()));
|
||||||
|
template.setCategory(category);
|
||||||
|
}
|
||||||
|
if (template.getPeriod() != null && template.getPeriod().getId() != null) {
|
||||||
|
PaymentPeriod period = paymentPeriodRepository.findById(template.getPeriod().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + template.getPeriod().getId()));
|
||||||
|
template.setPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
template.setCreatedBy(user);
|
||||||
|
|
||||||
|
return expenseTemplateRepository.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseTemplate update(Long id, ExpenseTemplate updated) {
|
||||||
|
ExpenseTemplate template = findById(id);
|
||||||
|
|
||||||
|
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(updated.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
|
||||||
|
template.setProperty(property);
|
||||||
|
} else {
|
||||||
|
template.setProperty(null);
|
||||||
|
}
|
||||||
|
if (updated.getPropertyGroup() != null && updated.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(updated.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", updated.getPropertyGroup().getId()));
|
||||||
|
template.setPropertyGroup(group);
|
||||||
|
} else {
|
||||||
|
template.setPropertyGroup(null);
|
||||||
|
}
|
||||||
|
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
|
||||||
|
template.setBankAccount(bank);
|
||||||
|
} else {
|
||||||
|
template.setBankAccount(null);
|
||||||
|
}
|
||||||
|
template.setIsDomiciled(updated.getIsDomiciled());
|
||||||
|
|
||||||
|
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
|
||||||
|
ExpenseCategory category = expenseCategoryRepository.findById(updated.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de gasto", updated.getCategory().getId()));
|
||||||
|
template.setCategory(category);
|
||||||
|
} else {
|
||||||
|
template.setCategory(null);
|
||||||
|
}
|
||||||
|
if (updated.getPeriod() != null && updated.getPeriod().getId() != null) {
|
||||||
|
PaymentPeriod period = paymentPeriodRepository.findById(updated.getPeriod().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Periodo no encontrado con id: " + updated.getPeriod().getId()));
|
||||||
|
template.setPeriod(period);
|
||||||
|
}
|
||||||
|
|
||||||
|
template.setPaymentDay(updated.getPaymentDay());
|
||||||
|
template.setSupplierName(updated.getSupplierName());
|
||||||
|
template.setSupplierFiscalId(updated.getSupplierFiscalId());
|
||||||
|
template.setAmount(updated.getAmount());
|
||||||
|
template.setTaxAmount(updated.getTaxAmount());
|
||||||
|
template.setDescription(updated.getDescription());
|
||||||
|
template.setNotes(updated.getNotes());
|
||||||
|
template.setIsVariable(updated.getIsVariable());
|
||||||
|
template.setActive(updated.getActive());
|
||||||
|
|
||||||
|
return expenseTemplateRepository.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
ExpenseTemplate template = findById(id);
|
||||||
|
template.setActive(false);
|
||||||
|
expenseTemplateRepository.save(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExpenseTemplate toggleActive(Long id) {
|
||||||
|
ExpenseTemplate template = findById(id);
|
||||||
|
template.setActive(!template.getActive());
|
||||||
|
return expenseTemplateRepository.save(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "income_categories")
|
||||||
|
public class IncomeCategory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(length = 255)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface IncomeCategoryRepository extends JpaRepository<IncomeCategory, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import com.sapolar.contract.Contract;
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.tenant.Tenant;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "income_receipts", indexes = {
|
||||||
|
@Index(name = "idx_income_receipts_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_income_receipts_contract", columnList = "contract_id"),
|
||||||
|
@Index(name = "idx_income_receipts_tenant", columnList = "tenant_id"),
|
||||||
|
@Index(name = "idx_income_receipts_status", columnList = "status_id"),
|
||||||
|
@Index(name = "idx_income_receipts_period_label", columnList = "period_label"),
|
||||||
|
@Index(name = "idx_income_receipts_issue_date", columnList = "issue_date")
|
||||||
|
})
|
||||||
|
public class IncomeReceipt {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "contract_id")
|
||||||
|
private Contract contract;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id", nullable = false)
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "tenant_id")
|
||||||
|
private Tenant tenant;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "bank_account_id")
|
||||||
|
private BankAccount bankAccount;
|
||||||
|
|
||||||
|
@Column(name = "is_domiciled", nullable = false)
|
||||||
|
private Boolean isDomiciled = false;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "category_id")
|
||||||
|
private IncomeCategory category;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "status_id", nullable = false)
|
||||||
|
private IncomeStatus status;
|
||||||
|
|
||||||
|
@Column(name = "period_label", length = 20)
|
||||||
|
private String periodLabel;
|
||||||
|
|
||||||
|
@Column(nullable = false, precision = 12, scale = 2)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "tax_withheld", precision = 12, scale = 2)
|
||||||
|
private BigDecimal taxWithheld = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "net_amount", precision = 12, scale = 2)
|
||||||
|
private BigDecimal netAmount;
|
||||||
|
|
||||||
|
@Column(name = "issue_date", nullable = false)
|
||||||
|
private LocalDate issueDate;
|
||||||
|
|
||||||
|
@Column(name = "due_date")
|
||||||
|
private LocalDate dueDate;
|
||||||
|
|
||||||
|
@Column(name = "payment_date")
|
||||||
|
private LocalDate paymentDate;
|
||||||
|
|
||||||
|
@Column(name = "payment_method", length = 30)
|
||||||
|
private String paymentMethod;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "receipt_number", length = 50)
|
||||||
|
private String receiptNumber;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private User createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.common.dto.PagedResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/income-receipts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncomeReceiptController {
|
||||||
|
|
||||||
|
private final IncomeReceiptService incomeReceiptService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<PagedResponse<IncomeReceipt>>> findAll(
|
||||||
|
@RequestParam(required = false) Long contractId,
|
||||||
|
@RequestParam(required = false) Long propertyId,
|
||||||
|
@RequestParam(required = false) String periodLabel,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "id") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String dir) {
|
||||||
|
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
|
||||||
|
Pageable pageable = PageRequest.of(page, size, sorting);
|
||||||
|
if (contractId != null) {
|
||||||
|
List<IncomeReceipt> list = incomeReceiptService.findByContract(contractId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
if (propertyId != null) {
|
||||||
|
List<IncomeReceipt> list = incomeReceiptService.findByProperty(propertyId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
if (periodLabel != null) {
|
||||||
|
List<IncomeReceipt> list = incomeReceiptService.findByPeriodLabel(periodLabel);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incomeReceiptService.findAll(pageable))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public ResponseEntity<ApiResponse<List<IncomeReceipt>>> getPending() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findPending()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending/count")
|
||||||
|
public ResponseEntity<ApiResponse<Long>> getPendingCount() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.countPending()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(incomeReceiptService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> create(@Valid @RequestBody IncomeReceipt receipt,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso creado",
|
||||||
|
incomeReceiptService.create(receipt, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> update(@PathVariable Long id,
|
||||||
|
@Valid @RequestBody IncomeReceipt receipt) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso actualizado",
|
||||||
|
incomeReceiptService.update(id, receipt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/pay")
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> registerPayment(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String paymentDateStr = body.get("paymentDate");
|
||||||
|
if (paymentDateStr == null) {
|
||||||
|
paymentDateStr = LocalDate.now().toString();
|
||||||
|
}
|
||||||
|
String paymentMethodStr = body.getOrDefault("paymentMethod", "TRANSFERENCIA");
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Pago registrado",
|
||||||
|
incomeReceiptService.registerPayment(id, paymentDateStr, paymentMethodStr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
incomeReceiptService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo de ingreso eliminado", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface IncomeReceiptRepository extends JpaRepository<IncomeReceipt, Long> {
|
||||||
|
List<IncomeReceipt> findByContractId(Long contractId);
|
||||||
|
|
||||||
|
List<IncomeReceipt> findByPropertyId(Long propertyId);
|
||||||
|
|
||||||
|
List<IncomeReceipt> findByStatusId(Integer statusId);
|
||||||
|
|
||||||
|
Optional<IncomeReceipt> findByContractIdAndPeriodLabel(Long contractId, String periodLabel);
|
||||||
|
|
||||||
|
List<IncomeReceipt> findByPeriodLabel(String periodLabel);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.property.id = :propertyId " +
|
||||||
|
"AND r.status.name = 'PAGADO' AND r.issueDate BETWEEN :start AND :end")
|
||||||
|
BigDecimal sumPaidByPropertyBetween(@Param("propertyId") Long propertyId, @Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r WHERE r.status.name = 'PAGADO' " +
|
||||||
|
"AND r.issueDate BETWEEN :start AND :end")
|
||||||
|
BigDecimal sumPaidBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||||
|
|
||||||
|
long countByStatusId(Integer statusId);
|
||||||
|
|
||||||
|
@Query("SELECT r FROM IncomeReceipt r JOIN FETCH r.property p " +
|
||||||
|
"WHERE r.status.name IN ('PENDIENTE', 'VENCIDO') ORDER BY r.dueDate ASC")
|
||||||
|
List<IncomeReceipt> findPendingOrderByDueDate();
|
||||||
|
|
||||||
|
@Query("SELECT c.name, COALESCE(SUM(r.netAmount), 0) FROM IncomeReceipt r JOIN r.category c " +
|
||||||
|
"WHERE YEAR(r.issueDate) = :year AND r.status.name IN ('PAGADO', 'PARCIAL') " +
|
||||||
|
"GROUP BY c.name ORDER BY SUM(r.netAmount) DESC")
|
||||||
|
List<Object[]> sumByCategoryForYear(@Param("year") int year);
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.contract.Contract;
|
||||||
|
import com.sapolar.contract.ContractRepository;
|
||||||
|
import com.sapolar.finance.bank.BankAccount;
|
||||||
|
import com.sapolar.finance.bank.BankAccountRepository;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.tenant.Tenant;
|
||||||
|
import com.sapolar.tenant.TenantRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncomeReceiptService {
|
||||||
|
|
||||||
|
private final IncomeReceiptRepository incomeReceiptRepository;
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final TenantRepository tenantRepository;
|
||||||
|
private final BankAccountRepository bankAccountRepository;
|
||||||
|
private final IncomeCategoryRepository incomeCategoryRepository;
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findAll() {
|
||||||
|
return incomeReceiptRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<IncomeReceipt> findAll(Pageable pageable) {
|
||||||
|
return incomeReceiptRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IncomeReceipt findById(Long id) {
|
||||||
|
return incomeReceiptRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Recibo de ingreso", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findByContract(Long contractId) {
|
||||||
|
return incomeReceiptRepository.findByContractId(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findByProperty(Long propertyId) {
|
||||||
|
return incomeReceiptRepository.findByPropertyId(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findByPeriodLabel(String periodLabel) {
|
||||||
|
return incomeReceiptRepository.findByPeriodLabel(periodLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findPending() {
|
||||||
|
return incomeReceiptRepository.findByStatusId(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countPending() {
|
||||||
|
return incomeReceiptRepository.countByStatusId(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IncomeReceipt create(IncomeReceipt receipt, Long userId) {
|
||||||
|
if (receipt.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
|
||||||
|
throw new BadRequestException("El importe debe ser mayor que cero");
|
||||||
|
}
|
||||||
|
|
||||||
|
Contract contract = null;
|
||||||
|
if (receipt.getContract() != null && receipt.getContract().getId() != null) {
|
||||||
|
contract = contractRepository.findById(receipt.getContract().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Contrato", receipt.getContract().getId()));
|
||||||
|
receipt.setContract(contract);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getProperty() != null && receipt.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(receipt.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", receipt.getProperty().getId()));
|
||||||
|
receipt.setProperty(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getTenant() != null && receipt.getTenant().getId() != null) {
|
||||||
|
Tenant tenant = tenantRepository.findById(receipt.getTenant().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Inquilino", receipt.getTenant().getId()));
|
||||||
|
receipt.setTenant(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getBankAccount() != null && receipt.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(receipt.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", receipt.getBankAccount().getId()));
|
||||||
|
receipt.setBankAccount(bank);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getCategory() != null && receipt.getCategory().getId() != null) {
|
||||||
|
IncomeCategory category = incomeCategoryRepository.findById(receipt.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", receipt.getCategory().getId()));
|
||||||
|
receipt.setCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getNetAmount() == null) {
|
||||||
|
BigDecimal tax = receipt.getTaxWithheld() != null ? receipt.getTaxWithheld() : BigDecimal.ZERO;
|
||||||
|
receipt.setNetAmount(receipt.getAmount().subtract(tax));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipt.getStatus() == null) {
|
||||||
|
IncomeStatus status = new IncomeStatus();
|
||||||
|
status.setId(1);
|
||||||
|
receipt.setStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
receipt.setCreatedBy(user);
|
||||||
|
|
||||||
|
return incomeReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IncomeReceipt update(Long id, IncomeReceipt updated) {
|
||||||
|
IncomeReceipt receipt = findById(id);
|
||||||
|
|
||||||
|
if (updated.getContract() != null && updated.getContract().getId() != null) {
|
||||||
|
Contract contract = contractRepository.findById(updated.getContract().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Contrato", updated.getContract().getId()));
|
||||||
|
receipt.setContract(contract);
|
||||||
|
} else {
|
||||||
|
receipt.setContract(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getProperty() != null && updated.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(updated.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", updated.getProperty().getId()));
|
||||||
|
receipt.setProperty(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getTenant() != null && updated.getTenant().getId() != null) {
|
||||||
|
Tenant tenant = tenantRepository.findById(updated.getTenant().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Inquilino", updated.getTenant().getId()));
|
||||||
|
receipt.setTenant(tenant);
|
||||||
|
} else {
|
||||||
|
receipt.setTenant(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getBankAccount() != null && updated.getBankAccount().getId() != null) {
|
||||||
|
BankAccount bank = bankAccountRepository.findById(updated.getBankAccount().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Cuenta bancaria", updated.getBankAccount().getId()));
|
||||||
|
receipt.setBankAccount(bank);
|
||||||
|
} else {
|
||||||
|
receipt.setBankAccount(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt.setIsDomiciled(updated.getIsDomiciled());
|
||||||
|
|
||||||
|
if (updated.getCategory() != null && updated.getCategory().getId() != null) {
|
||||||
|
IncomeCategory category = incomeCategoryRepository.findById(updated.getCategory().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Categoría de ingreso", updated.getCategory().getId()));
|
||||||
|
receipt.setCategory(category);
|
||||||
|
} else {
|
||||||
|
receipt.setCategory(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.getStatus() != null && updated.getStatus().getId() != null) {
|
||||||
|
IncomeStatus status = new IncomeStatus();
|
||||||
|
status.setId(updated.getStatus().getId());
|
||||||
|
receipt.setStatus(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
receipt.setPeriodLabel(updated.getPeriodLabel());
|
||||||
|
receipt.setAmount(updated.getAmount());
|
||||||
|
receipt.setTaxWithheld(updated.getTaxWithheld());
|
||||||
|
receipt.setNetAmount(updated.getNetAmount());
|
||||||
|
receipt.setIssueDate(updated.getIssueDate());
|
||||||
|
receipt.setDueDate(updated.getDueDate());
|
||||||
|
receipt.setPaymentDate(updated.getPaymentDate());
|
||||||
|
receipt.setPaymentMethod(updated.getPaymentMethod());
|
||||||
|
receipt.setDescription(updated.getDescription());
|
||||||
|
receipt.setReceiptNumber(updated.getReceiptNumber());
|
||||||
|
receipt.setNotes(updated.getNotes());
|
||||||
|
|
||||||
|
return incomeReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IncomeReceipt registerPayment(Long id, String paymentDate, String paymentMethod) {
|
||||||
|
IncomeReceipt receipt = findById(id);
|
||||||
|
receipt.setPaymentDate(LocalDate.parse(paymentDate));
|
||||||
|
receipt.setPaymentMethod(paymentMethod);
|
||||||
|
|
||||||
|
IncomeStatus status = new IncomeStatus();
|
||||||
|
status.setId(2);
|
||||||
|
receipt.setStatus(status);
|
||||||
|
|
||||||
|
return incomeReceiptRepository.save(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
IncomeReceipt receipt = findById(id);
|
||||||
|
incomeReceiptRepository.delete(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.finance.income;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "income_statuses")
|
||||||
|
public class IncomeStatus {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "email_log")
|
||||||
|
public class EmailLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "income_receipt_id")
|
||||||
|
private Long incomeReceiptId;
|
||||||
|
|
||||||
|
@Column(name = "recipient_email", nullable = false, length = 200)
|
||||||
|
private String recipientEmail;
|
||||||
|
|
||||||
|
@Column(name = "subject", nullable = false, length = 300)
|
||||||
|
private String subject;
|
||||||
|
|
||||||
|
@Column(name = "body", columnDefinition = "TEXT")
|
||||||
|
private String body;
|
||||||
|
|
||||||
|
@Column(name = "success", nullable = false)
|
||||||
|
private Boolean success = false;
|
||||||
|
|
||||||
|
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@Column(name = "sent_at", nullable = false)
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
sentAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface EmailLogRepository extends JpaRepository<EmailLog, Long> {
|
||||||
|
List<EmailLog> findByIncomeReceiptIdOrderBySentAtDesc(Long incomeReceiptId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.sapolar.finance.income.IncomeReceipt;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EmailReceiptService {
|
||||||
|
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final EmailLogRepository emailLogRepository;
|
||||||
|
|
||||||
|
@Value("${app.receipt.from-email:noreply@sapolar.com}")
|
||||||
|
private String fromEmail;
|
||||||
|
|
||||||
|
public void sendReceipt(IncomeReceipt income, byte[] pdf, String toEmail) {
|
||||||
|
EmailLog log = new EmailLog();
|
||||||
|
log.setIncomeReceiptId(income.getId());
|
||||||
|
log.setRecipientEmail(toEmail);
|
||||||
|
log.setSubject("Recibo de alquiler - " + income.getReceiptNumber());
|
||||||
|
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||||
|
|
||||||
|
helper.setFrom(fromEmail);
|
||||||
|
helper.setTo(toEmail);
|
||||||
|
helper.setSubject(log.getSubject());
|
||||||
|
|
||||||
|
String body = buildEmailBody(income);
|
||||||
|
helper.setText(body, true);
|
||||||
|
|
||||||
|
String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : income.getId()) + ".pdf";
|
||||||
|
helper.addAttachment(filename, () -> new java.io.ByteArrayInputStream(pdf));
|
||||||
|
|
||||||
|
mailSender.send(message);
|
||||||
|
|
||||||
|
log.setSuccess(true);
|
||||||
|
log.setBody(body);
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.setSuccess(false);
|
||||||
|
log.setErrorMessage(e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
emailLogRepository.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildEmailBody(IncomeReceipt income) {
|
||||||
|
return "<html><body>" +
|
||||||
|
"<h2>SA POLAR - Gestión de Alquileres</h2>" +
|
||||||
|
"<p>Estimado/a " + (income.getTenant() != null ? income.getTenant().getFullName() : "") + ",</p>" +
|
||||||
|
"<p>Adjuntamos el recibo de alquiler correspondiente.</p>" +
|
||||||
|
"<p><strong>Recibo Nº:</strong> " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N") + "</p>" +
|
||||||
|
"<p><strong>Propiedad:</strong> " + (income.getProperty() != null ? income.getProperty().getName() : "") + "</p>" +
|
||||||
|
"<p><strong>Importe:</strong> " + income.getAmount() + " €</p>" +
|
||||||
|
"<p><strong>Periodo:</strong> " + (income.getDescription() != null ? income.getDescription() : "") + "</p>" +
|
||||||
|
"<hr/><p><small>Este mensaje se ha generado automáticamente.</small></p>" +
|
||||||
|
"</body></html>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.itextpdf.kernel.pdf.PdfDocument;
|
||||||
|
import com.itextpdf.kernel.pdf.PdfWriter;
|
||||||
|
import com.itextpdf.layout.Document;
|
||||||
|
import com.itextpdf.layout.element.Cell;
|
||||||
|
import com.itextpdf.layout.element.Paragraph;
|
||||||
|
import com.itextpdf.layout.element.Table;
|
||||||
|
import com.itextpdf.layout.properties.UnitValue;
|
||||||
|
import com.sapolar.finance.income.IncomeReceipt;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.text.NumberFormat;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PdfReceiptService {
|
||||||
|
|
||||||
|
public byte[] generatePdf(IncomeReceipt income) {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
try (PdfDocument pdfDoc = new PdfDocument(new PdfWriter(baos));
|
||||||
|
Document document = new Document(pdfDoc)) {
|
||||||
|
|
||||||
|
document.add(new Paragraph("SA POLAR - GESTIÓN DE ALQUILERES")
|
||||||
|
.setBold().setFontSize(18));
|
||||||
|
document.add(new Paragraph("CIF/NIF: B-12345678"));
|
||||||
|
document.add(new Paragraph("C/ Ejemplo, 123 - 07001 Palma"));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
|
||||||
|
document.add(new Paragraph("RECIBO Nº: " + (income.getReceiptNumber() != null ? income.getReceiptNumber() : "S/N"))
|
||||||
|
.setBold().setFontSize(14));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
document.add(new Paragraph("Fecha de emisión: " + income.getIssueDate()));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
|
||||||
|
Table infoTable = new Table(UnitValue.createPercentArray(new float[]{30, 70}));
|
||||||
|
infoTable.addCell(createLabelCell("Inquilino/a:"));
|
||||||
|
infoTable.addCell(createValueCell(
|
||||||
|
income.getTenant() != null ? income.getTenant().getFullName() : ""));
|
||||||
|
infoTable.addCell(createLabelCell("NIF/CIF:"));
|
||||||
|
infoTable.addCell(createValueCell(
|
||||||
|
income.getTenant() != null ? income.getTenant().getFiscalId() : ""));
|
||||||
|
infoTable.addCell(createLabelCell("Propiedad:"));
|
||||||
|
infoTable.addCell(createValueCell(
|
||||||
|
income.getProperty() != null ? income.getProperty().getName() : ""));
|
||||||
|
infoTable.addCell(createLabelCell("Dirección:"));
|
||||||
|
infoTable.addCell(createValueCell(
|
||||||
|
income.getProperty() != null ? income.getProperty().getAddressStreet() : ""));
|
||||||
|
infoTable.addCell(createLabelCell("Período:"));
|
||||||
|
infoTable.addCell(createValueCell(income.getDescription()));
|
||||||
|
document.add(infoTable);
|
||||||
|
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
|
||||||
|
Table amountTable = new Table(UnitValue.createPercentArray(new float[]{70, 30}));
|
||||||
|
amountTable.addCell(createHeaderCell("Concepto"));
|
||||||
|
amountTable.addCell(createHeaderCell("Importe"));
|
||||||
|
|
||||||
|
String concept = income.getDescription() != null ? income.getDescription() : "Alquiler mensual";
|
||||||
|
amountTable.addCell(createValueCell(concept));
|
||||||
|
amountTable.addCell(createValueCell(formatCurrency(income.getAmount())));
|
||||||
|
|
||||||
|
if (income.getTaxWithheld() != null && income.getTaxWithheld().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
amountTable.addCell(createLabelCell("Retención IRPF"));
|
||||||
|
amountTable.addCell(createValueCell("-" + formatCurrency(income.getTaxWithheld())));
|
||||||
|
}
|
||||||
|
|
||||||
|
amountTable.addCell(createHeaderCell("TOTAL"));
|
||||||
|
amountTable.addCell(createHeaderCell(formatCurrency(
|
||||||
|
income.getNetAmount() != null ? income.getNetAmount() : income.getAmount())));
|
||||||
|
|
||||||
|
document.add(amountTable);
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
document.add(new Paragraph(""));
|
||||||
|
|
||||||
|
document.add(new Paragraph("Forma de pago: " +
|
||||||
|
(income.getPaymentMethod() != null ? income.getPaymentMethod() : "Pendiente")));
|
||||||
|
if (income.getPaymentDate() != null) {
|
||||||
|
document.add(new Paragraph("Fecha de pago: " + income.getPaymentDate()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Error al generar el PDF del recibo", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cell createLabelCell(String text) {
|
||||||
|
return new Cell().add(new Paragraph(text).setBold())
|
||||||
|
.setPadding(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cell createValueCell(String text) {
|
||||||
|
return new Cell().add(new Paragraph(text != null ? text : ""))
|
||||||
|
.setPadding(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cell createHeaderCell(String text) {
|
||||||
|
return new Cell().add(new Paragraph(text).setBold())
|
||||||
|
.setPadding(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatCurrency(BigDecimal amount) {
|
||||||
|
if (amount == null) return "0,00 €";
|
||||||
|
NumberFormat nf = NumberFormat.getNumberInstance(new Locale("es", "ES"));
|
||||||
|
nf.setMinimumFractionDigits(2);
|
||||||
|
nf.setMaximumFractionDigits(2);
|
||||||
|
return nf.format(amount) + " €";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.finance.income.IncomeReceipt;
|
||||||
|
import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/receipts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReceiptController {
|
||||||
|
|
||||||
|
private final ReceiptService receiptService;
|
||||||
|
private final ReportService reportService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Listar todos los recibos")
|
||||||
|
public ResponseEntity<ApiResponse<List<IncomeReceipt>>> findAll() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(receiptService.findAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "Obtener recibo por ID")
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(receiptService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
@Operation(summary = "Generar un recibo manualmente")
|
||||||
|
public ResponseEntity<ApiResponse<IncomeReceipt>> generate(@RequestBody ReceiptGenerateRequest request) {
|
||||||
|
IncomeReceipt income = receiptService.generateReceipt(request);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo generado correctamente", income));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate-monthly")
|
||||||
|
@Operation(summary = "Generar recibos mensuales para todos los contratos activos")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> generateMonthly() {
|
||||||
|
receiptService.generateMonthlyReceipts();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibos mensuales generados"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/pdf")
|
||||||
|
@Operation(summary = "Descargar PDF del recibo")
|
||||||
|
public ResponseEntity<ByteArrayResource> downloadPdf(@PathVariable Long id) {
|
||||||
|
byte[] pdf = receiptService.generateReceiptPdf(id);
|
||||||
|
IncomeReceipt income = receiptService.findById(id);
|
||||||
|
String filename = "recibo_" + (income.getReceiptNumber() != null ? income.getReceiptNumber() : id) + ".pdf";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.contentLength(pdf.length)
|
||||||
|
.body(new ByteArrayResource(pdf));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/send-email")
|
||||||
|
@Operation(summary = "Enviar recibo por email")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> sendEmail(@PathVariable Long id,
|
||||||
|
@RequestParam String email) {
|
||||||
|
receiptService.sendReceiptEmail(id, email);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Recibo enviado por email"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/reports/monthly")
|
||||||
|
@Operation(summary = "Descargar informe mensual Excel")
|
||||||
|
@PreAuthorize("hasAnyRole('ADMIN', 'GERENTE', 'CONTABLE')")
|
||||||
|
public ResponseEntity<ByteArrayResource> downloadMonthlyReport(
|
||||||
|
@RequestParam int year, @RequestParam int month) {
|
||||||
|
byte[] report = reportService.generateMonthlyReport(year, month);
|
||||||
|
String filename = "informe_" + year + "_" + String.format("%02d", month) + ".xlsx";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.contentLength(report.length)
|
||||||
|
.body(new ByteArrayResource(report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.sapolar.contract.Contract;
|
||||||
|
import com.sapolar.contract.ContractRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReceiptScheduler {
|
||||||
|
|
||||||
|
private final ReceiptService receiptService;
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
private final EmailReceiptService emailReceiptService;
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 0 6 1 * ?")
|
||||||
|
@Transactional
|
||||||
|
public void generateMonthlyReceipts() {
|
||||||
|
log.info("Iniciando generación automática de recibos mensuales...");
|
||||||
|
receiptService.generateMonthlyReceipts();
|
||||||
|
log.info("Generación de recibos completada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 0 2 * * ?")
|
||||||
|
@Transactional
|
||||||
|
public void markOverdueIncomes() {
|
||||||
|
log.info("Iniciando marcado de recibos vencidos...");
|
||||||
|
receiptService.markOverdueIncomes();
|
||||||
|
log.info("Marcado de recibos vencidos completado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 0 7 1 * ?")
|
||||||
|
@Transactional
|
||||||
|
public void checkExpiringContracts() {
|
||||||
|
log.info("Revisando contratos próximos a vencer...");
|
||||||
|
LocalDate start = LocalDate.now();
|
||||||
|
LocalDate end = start.plusMonths(1);
|
||||||
|
List<Contract> expiring = contractRepository.findContractsExpiringBetween(start, end);
|
||||||
|
if (!expiring.isEmpty()) {
|
||||||
|
log.warn("Se encontraron {} contratos próximos a vencer", expiring.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "receipt_series")
|
||||||
|
public class ReceiptSeries {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "series_name", nullable = false, length = 50)
|
||||||
|
private String seriesName;
|
||||||
|
|
||||||
|
@Column(name = "fiscal_year", nullable = false)
|
||||||
|
private Integer fiscalYear;
|
||||||
|
|
||||||
|
@Column(name = "last_number", nullable = false)
|
||||||
|
private Integer lastNumber = 0;
|
||||||
|
|
||||||
|
@Column(name = "prefix", length = 20)
|
||||||
|
private String prefix;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private Boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ReceiptSeriesRepository extends JpaRepository<ReceiptSeries, Long> {
|
||||||
|
Optional<ReceiptSeries> findByFiscalYearAndSeriesName(Integer fiscalYear, String seriesName);
|
||||||
|
Optional<ReceiptSeries> findByFiscalYearAndActiveTrue(Integer fiscalYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.BadRequestException;
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.contract.Contract;
|
||||||
|
import com.sapolar.contract.ContractRepository;
|
||||||
|
import com.sapolar.contract.ContractTenant;
|
||||||
|
import com.sapolar.contract.ContractTenantRepository;
|
||||||
|
import com.sapolar.finance.income.*;
|
||||||
|
import com.sapolar.finance.receipt.dto.ReceiptGenerateRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReceiptService {
|
||||||
|
|
||||||
|
private final IncomeReceiptRepository incomeReceiptRepository;
|
||||||
|
private final ContractRepository contractRepository;
|
||||||
|
private final ContractTenantRepository contractTenantRepository;
|
||||||
|
private final ReceiptSeriesRepository receiptSeriesRepository;
|
||||||
|
private final PdfReceiptService pdfReceiptService;
|
||||||
|
private final EmailReceiptService emailReceiptService;
|
||||||
|
|
||||||
|
public List<IncomeReceipt> findAll() {
|
||||||
|
return incomeReceiptRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IncomeReceipt findById(Long id) {
|
||||||
|
return incomeReceiptRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Recibo", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public IncomeReceipt generateReceipt(ReceiptGenerateRequest request) {
|
||||||
|
Contract contract = contractRepository.findById(request.getContractId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Contrato", request.getContractId()));
|
||||||
|
|
||||||
|
if (!"ACTIVO".equals(contract.getStatus().getName())) {
|
||||||
|
throw new BadRequestException("El contrato no está activo");
|
||||||
|
}
|
||||||
|
|
||||||
|
IncomeReceipt income = new IncomeReceipt();
|
||||||
|
income.setContract(contract);
|
||||||
|
income.setProperty(contract.getProperty());
|
||||||
|
List<ContractTenant> cts = contractTenantRepository.findByContractId(contract.getId());
|
||||||
|
income.setTenant(cts.stream().filter(ct -> "TITULAR".equals(ct.getRole()))
|
||||||
|
.findFirst().map(ContractTenant::getTenant)
|
||||||
|
.orElseGet(() -> cts.isEmpty() ? null : cts.get(0).getTenant()));
|
||||||
|
|
||||||
|
IncomeCategory defaultCategory = new IncomeCategory();
|
||||||
|
defaultCategory.setId(1L);
|
||||||
|
income.setCategory(defaultCategory);
|
||||||
|
|
||||||
|
IncomeStatus pendingStatus = new IncomeStatus();
|
||||||
|
pendingStatus.setId(1);
|
||||||
|
income.setStatus(pendingStatus);
|
||||||
|
|
||||||
|
income.setAmount(contract.getRentalAmount());
|
||||||
|
income.setNetAmount(contract.getRentalAmount());
|
||||||
|
income.setIssueDate(request.getIssueDate() != null ? request.getIssueDate() : LocalDate.now());
|
||||||
|
income.setDueDate(request.getDueDate() != null ? request.getDueDate()
|
||||||
|
: income.getIssueDate().plusDays(30));
|
||||||
|
income.setReceiptNumber(generateReceiptNumber(income.getIssueDate().getYear()));
|
||||||
|
|
||||||
|
// Period label based on issue date
|
||||||
|
String periodLabel = income.getIssueDate().getYear() + "-"
|
||||||
|
+ String.format("%02d", income.getIssueDate().getMonthValue());
|
||||||
|
income.setPeriodLabel(periodLabel);
|
||||||
|
|
||||||
|
income.setDescription(request.getDescription() != null ? request.getDescription()
|
||||||
|
: "Recibo mensual alquiler - " + contract.getProperty().getName());
|
||||||
|
|
||||||
|
return incomeReceiptRepository.save(income);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void generateMonthlyReceipts() {
|
||||||
|
List<Contract> activeContracts = contractRepository.findByStatusId(1);
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
int currentMonth = today.getMonthValue();
|
||||||
|
int currentYear = today.getYear();
|
||||||
|
String periodLabel = currentYear + "-" + String.format("%02d", currentMonth);
|
||||||
|
|
||||||
|
for (Contract contract : activeContracts) {
|
||||||
|
Integer periodId = contract.getPaymentPeriod() != null
|
||||||
|
? contract.getPaymentPeriod().getId() : 1;
|
||||||
|
|
||||||
|
// Determinar si este mes toca generar según la periodicidad
|
||||||
|
if (!shouldGenerateForPeriod(periodId, contract.getStartDate(), currentMonth, currentYear)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evitar duplicados por contrato+periodo
|
||||||
|
if (incomeReceiptRepository.findByContractIdAndPeriodLabel(contract.getId(), periodLabel).isPresent()) {
|
||||||
|
log.debug("Ya existe recibo para contrato {} en periodo {}", contract.getId(), periodLabel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular día de emisión: usa paymentDay, sin pasarse de los días del mes
|
||||||
|
int day = contract.getPaymentDay() != null
|
||||||
|
? Math.min(contract.getPaymentDay(), today.lengthOfMonth())
|
||||||
|
: today.lengthOfMonth();
|
||||||
|
LocalDate issueDate = LocalDate.of(currentYear, currentMonth, day);
|
||||||
|
LocalDate dueDate = issueDate.plusDays(30);
|
||||||
|
|
||||||
|
ReceiptGenerateRequest req = new ReceiptGenerateRequest();
|
||||||
|
req.setContractId(contract.getId());
|
||||||
|
req.setIssueDate(issueDate);
|
||||||
|
req.setDueDate(dueDate);
|
||||||
|
generateReceipt(req);
|
||||||
|
|
||||||
|
log.info("Recibo generado para contrato {} - periodo {}", contract.getId(), periodLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina si se debe generar recibo este mes según la periodicidad del contrato.
|
||||||
|
* periodId: 1=MENSUAL, 2=TRIMESTRAL, 3=SEMESTRAL, 4=ANUAL
|
||||||
|
*/
|
||||||
|
private boolean shouldGenerateForPeriod(Integer periodId, LocalDate startDate,
|
||||||
|
int currentMonth, int currentYear) {
|
||||||
|
if (periodId == null || periodId == 1) {
|
||||||
|
return true; // MENSUAL: siempre generar
|
||||||
|
}
|
||||||
|
// Para períodos >1 mes, calcular los meses transcurridos desde el inicio
|
||||||
|
int monthsSinceStart = (currentYear - startDate.getYear()) * 12
|
||||||
|
+ (currentMonth - startDate.getMonthValue());
|
||||||
|
if (monthsSinceStart < 0) return false; // contrato aún no ha empezado
|
||||||
|
|
||||||
|
return switch (periodId) {
|
||||||
|
case 2 -> monthsSinceStart % 3 == 0; // TRIMESTRAL: cada 3 meses
|
||||||
|
case 3 -> monthsSinceStart % 6 == 0; // SEMESTRAL: cada 6 meses
|
||||||
|
case 4 -> monthsSinceStart % 12 == 0; // ANUAL: cada 12 meses
|
||||||
|
default -> true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markOverdueIncomes() {
|
||||||
|
IncomeStatus overdueStatus = new IncomeStatus();
|
||||||
|
overdueStatus.setId(3);
|
||||||
|
|
||||||
|
List<IncomeReceipt> pending = incomeReceiptRepository.findByStatusId(1);
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
|
||||||
|
for (IncomeReceipt income : pending) {
|
||||||
|
if (income.getDueDate() != null && income.getDueDate().isBefore(today)) {
|
||||||
|
income.setStatus(overdueStatus);
|
||||||
|
incomeReceiptRepository.save(income);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generateReceiptPdf(Long incomeId) {
|
||||||
|
IncomeReceipt income = findById(incomeId);
|
||||||
|
return pdfReceiptService.generatePdf(income);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendReceiptEmail(Long incomeId, String toEmail) {
|
||||||
|
IncomeReceipt income = findById(incomeId);
|
||||||
|
byte[] pdf = pdfReceiptService.generatePdf(income);
|
||||||
|
emailReceiptService.sendReceipt(income, pdf, toEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateReceiptNumber(int fiscalYear) {
|
||||||
|
ReceiptSeries series = receiptSeriesRepository
|
||||||
|
.findByFiscalYearAndActiveTrue(fiscalYear)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ReceiptSeries newSeries = new ReceiptSeries();
|
||||||
|
newSeries.setSeriesName("RECIBOS");
|
||||||
|
newSeries.setFiscalYear(fiscalYear);
|
||||||
|
newSeries.setPrefix("R-" + fiscalYear + "-");
|
||||||
|
newSeries.setLastNumber(0);
|
||||||
|
newSeries.setActive(true);
|
||||||
|
return receiptSeriesRepository.save(newSeries);
|
||||||
|
});
|
||||||
|
|
||||||
|
series.setLastNumber(series.getLastNumber() + 1);
|
||||||
|
receiptSeriesRepository.save(series);
|
||||||
|
return series.getPrefix() + String.format("%05d", series.getLastNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.sapolar.finance.receipt;
|
||||||
|
|
||||||
|
import com.sapolar.finance.expense.ExpenseReceiptRepository;
|
||||||
|
import com.sapolar.finance.income.IncomeReceiptRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.poi.ss.usermodel.*;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReportService {
|
||||||
|
|
||||||
|
private final IncomeReceiptRepository incomeReceiptRepository;
|
||||||
|
private final ExpenseReceiptRepository expenseReceiptRepository;
|
||||||
|
|
||||||
|
public byte[] generateMonthlyReport(int year, int month) {
|
||||||
|
LocalDate start = LocalDate.of(year, month, 1);
|
||||||
|
LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
|
||||||
|
|
||||||
|
BigDecimal totalIncome = incomeReceiptRepository.sumPaidBetween(start, end);
|
||||||
|
BigDecimal totalExpense = expenseReceiptRepository.sumPaidBetween(start, end);
|
||||||
|
|
||||||
|
totalIncome = totalIncome != null ? totalIncome : BigDecimal.ZERO;
|
||||||
|
totalExpense = totalExpense != null ? totalExpense : BigDecimal.ZERO;
|
||||||
|
|
||||||
|
try (Workbook workbook = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = workbook.createSheet("Informe " + month + "-" + year);
|
||||||
|
|
||||||
|
CellStyle headerStyle = workbook.createCellStyle();
|
||||||
|
Font headerFont = workbook.createFont();
|
||||||
|
headerFont.setBold(true);
|
||||||
|
headerFont.setFontHeightInPoints((short) 12);
|
||||||
|
headerStyle.setFont(headerFont);
|
||||||
|
headerStyle.setFillForegroundColor(IndexedColors.LIGHT_BLUE.getIndex());
|
||||||
|
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||||
|
|
||||||
|
Row titleRow = sheet.createRow(0);
|
||||||
|
Cell titleCell = titleRow.createCell(0);
|
||||||
|
titleCell.setCellValue("Informe mensual - " + month + "/" + year);
|
||||||
|
titleCell.setCellStyle(headerStyle);
|
||||||
|
|
||||||
|
Row incomeRow = sheet.createRow(2);
|
||||||
|
incomeRow.createCell(0).setCellValue("Total Ingresos");
|
||||||
|
incomeRow.createCell(1).setCellValue(totalIncome.doubleValue());
|
||||||
|
|
||||||
|
Row expenseRow = sheet.createRow(3);
|
||||||
|
expenseRow.createCell(0).setCellValue("Total Gastos");
|
||||||
|
expenseRow.createCell(1).setCellValue(totalExpense.doubleValue());
|
||||||
|
|
||||||
|
Row balanceRow = sheet.createRow(5);
|
||||||
|
balanceRow.createCell(0).setCellValue("Saldo Neto");
|
||||||
|
CellStyle boldStyle = workbook.createCellStyle();
|
||||||
|
Font boldFont = workbook.createFont();
|
||||||
|
boldFont.setBold(true);
|
||||||
|
boldStyle.setFont(boldFont);
|
||||||
|
balanceRow.createCell(0).setCellStyle(boldStyle);
|
||||||
|
balanceRow.createCell(1).setCellValue(totalIncome.subtract(totalExpense).doubleValue());
|
||||||
|
|
||||||
|
sheet.autoSizeColumn(0);
|
||||||
|
sheet.autoSizeColumn(1);
|
||||||
|
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
workbook.write(baos);
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Error al generar informe Excel", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.sapolar.finance.receipt.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class ReceiptGenerateRequest {
|
||||||
|
private Long contractId;
|
||||||
|
private LocalDate issueDate;
|
||||||
|
private LocalDate dueDate;
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "incidents", indexes = {
|
||||||
|
@Index(name = "idx_incidents_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_incidents_status", columnList = "status_id"),
|
||||||
|
@Index(name = "idx_incidents_priority", columnList = "priority_id"),
|
||||||
|
@Index(name = "idx_incidents_reported", columnList = "reported_at")
|
||||||
|
})
|
||||||
|
public class Incident {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id")
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_group_id")
|
||||||
|
private PropertyGroup propertyGroup;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "status_id", nullable = false)
|
||||||
|
private IncidentStatus status;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "priority_id", nullable = false)
|
||||||
|
private IncidentPriority priority;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "reported_by")
|
||||||
|
private User reportedBy;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "assigned_to")
|
||||||
|
private User assignedTo;
|
||||||
|
|
||||||
|
@Column(name = "reported_at", nullable = false)
|
||||||
|
private LocalDateTime reportedAt;
|
||||||
|
|
||||||
|
@Column(name = "scheduled_date")
|
||||||
|
private LocalDate scheduledDate;
|
||||||
|
|
||||||
|
@Column(name = "resolved_at")
|
||||||
|
private LocalDateTime resolvedAt;
|
||||||
|
|
||||||
|
@Column(name = "resolution_notes", columnDefinition = "TEXT")
|
||||||
|
private String resolutionNotes;
|
||||||
|
|
||||||
|
@Column(name = "cost_estimate", precision = 12, scale = 2)
|
||||||
|
private BigDecimal costEstimate;
|
||||||
|
|
||||||
|
@Column(name = "final_cost", precision = 12, scale = 2)
|
||||||
|
private BigDecimal finalCost;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (reportedAt == null) {
|
||||||
|
reportedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.common.dto.PagedResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/incidents")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncidentController {
|
||||||
|
|
||||||
|
private final IncidentService incidentService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<PagedResponse<Incident>>> findAll(
|
||||||
|
@RequestParam(required = false) Long propertyId,
|
||||||
|
@RequestParam(required = false) Integer statusId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(defaultValue = "id") String sort,
|
||||||
|
@RequestParam(defaultValue = "asc") String dir) {
|
||||||
|
Sort sorting = dir.equalsIgnoreCase("desc") ? Sort.by(sort).descending() : Sort.by(sort).ascending();
|
||||||
|
Pageable pageable = PageRequest.of(page, size, sorting);
|
||||||
|
if (propertyId != null) {
|
||||||
|
List<Incident> list = incidentService.findByProperty(propertyId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
if (statusId != null) {
|
||||||
|
List<Incident> list = incidentService.findByStatus(statusId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new PagedResponse<>(list, 0, list.size(), list.size(), 1, true)));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(incidentService.findAll(pageable))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(incidentService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> create(@RequestBody Incident incident,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Incidencia creada",
|
||||||
|
incidentService.create(incident, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> update(@PathVariable Long id, @RequestBody Incident incident) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Incidencia actualizada",
|
||||||
|
incidentService.update(id, incident)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> updateStatus(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
Integer statusId = (Integer) body.get("statusId");
|
||||||
|
String notes = (String) body.get("resolutionNotes");
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Estado actualizado",
|
||||||
|
incidentService.updateStatus(id, statusId, notes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/assign")
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> assign(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Long> body) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Técnico asignado",
|
||||||
|
incidentService.assignTechnician(id, body.get("technicianId"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/schedule")
|
||||||
|
public ResponseEntity<ApiResponse<Incident>> schedule(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Reparación programada",
|
||||||
|
incidentService.scheduleRepair(id, java.time.LocalDate.parse(body.get("scheduledDate")))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
incidentService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Incidencia eliminada", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "incident_priorities")
|
||||||
|
public class IncidentPriority {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 20)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface IncidentRepository extends JpaRepository<Incident, Long> {
|
||||||
|
List<Incident> findByPropertyId(Long propertyId);
|
||||||
|
List<Incident> findByStatusId(Integer statusId);
|
||||||
|
List<Incident> findByPriorityId(Integer priorityId);
|
||||||
|
List<Incident> findByAssignedToId(Long userId);
|
||||||
|
List<Incident> findByReportedById(Long userId);
|
||||||
|
long countByStatusId(Integer statusId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.property.PropertyGroupRepository;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncidentService {
|
||||||
|
|
||||||
|
private final IncidentRepository incidentRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final PropertyGroupRepository propertyGroupRepository;
|
||||||
|
|
||||||
|
public List<Incident> findAll() {
|
||||||
|
return incidentRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<Incident> findAll(Pageable pageable) {
|
||||||
|
return incidentRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Incident> findByProperty(Long propertyId) {
|
||||||
|
return incidentRepository.findByPropertyId(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Incident> findByStatus(Integer statusId) {
|
||||||
|
return incidentRepository.findByStatusId(statusId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Incident findById(Long id) {
|
||||||
|
return incidentRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Incidencia", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Incident create(Incident incident, Long userId) {
|
||||||
|
// Asociar propiedad o conjunto
|
||||||
|
if (incident.getProperty() != null && incident.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(incident.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", incident.getProperty().getId()));
|
||||||
|
incident.setProperty(property);
|
||||||
|
}
|
||||||
|
if (incident.getPropertyGroup() != null && incident.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(incident.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", incident.getPropertyGroup().getId()));
|
||||||
|
incident.setPropertyGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
incident.setReportedBy(user);
|
||||||
|
|
||||||
|
return incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Incident updateStatus(Long id, Integer statusId, String resolutionNotes) {
|
||||||
|
Incident incident = findById(id);
|
||||||
|
IncidentStatus status = new IncidentStatus();
|
||||||
|
status.setId(statusId);
|
||||||
|
incident.setStatus(status);
|
||||||
|
|
||||||
|
if (statusId == 4) {
|
||||||
|
incident.setResolvedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
if (resolutionNotes != null) {
|
||||||
|
incident.setResolutionNotes(resolutionNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Incident assignTechnician(Long id, Long technicianId) {
|
||||||
|
Incident incident = findById(id);
|
||||||
|
User technician = new User();
|
||||||
|
technician.setId(technicianId);
|
||||||
|
incident.setAssignedTo(technician);
|
||||||
|
|
||||||
|
IncidentStatus status = new IncidentStatus();
|
||||||
|
status.setId(2);
|
||||||
|
incident.setStatus(status);
|
||||||
|
|
||||||
|
return incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Incident scheduleRepair(Long id, java.time.LocalDate scheduledDate) {
|
||||||
|
Incident incident = findById(id);
|
||||||
|
incident.setScheduledDate(scheduledDate);
|
||||||
|
|
||||||
|
IncidentStatus status = new IncidentStatus();
|
||||||
|
status.setId(3);
|
||||||
|
incident.setStatus(status);
|
||||||
|
|
||||||
|
return incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Incident update(Long id, Incident updated) {
|
||||||
|
Incident incident = findById(id);
|
||||||
|
incident.setTitle(updated.getTitle());
|
||||||
|
incident.setDescription(updated.getDescription());
|
||||||
|
if (updated.getPriority() != null) incident.setPriority(updated.getPriority());
|
||||||
|
if (updated.getStatus() != null) incident.setStatus(updated.getStatus());
|
||||||
|
if (updated.getCostEstimate() != null) incident.setCostEstimate(updated.getCostEstimate());
|
||||||
|
if (updated.getAssignedTo() != null) {
|
||||||
|
User technician = new User();
|
||||||
|
technician.setId(updated.getAssignedTo().getId());
|
||||||
|
incident.setAssignedTo(technician);
|
||||||
|
}
|
||||||
|
incident.setScheduledDate(updated.getScheduledDate());
|
||||||
|
incident.setResolutionNotes(updated.getResolutionNotes());
|
||||||
|
return incidentRepository.save(incident);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
incidentRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countOpen() {
|
||||||
|
return incidentRepository.countByStatusId(1)
|
||||||
|
+ incidentRepository.countByStatusId(2)
|
||||||
|
+ incidentRepository.countByStatusId(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.incident;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "incident_statuses")
|
||||||
|
public class IncidentStatus {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.sapolar.maintenance;
|
||||||
|
|
||||||
|
import com.sapolar.auth.SecurityUser;
|
||||||
|
import com.sapolar.common.dto.ApiResponse;
|
||||||
|
import com.sapolar.finance.expense.ExpenseCategory;
|
||||||
|
import com.sapolar.finance.expense.ExpenseCategoryRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/maintenance")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MaintenanceController {
|
||||||
|
|
||||||
|
private final MaintenanceService maintenanceService;
|
||||||
|
private final ExpenseCategoryRepository expenseCategoryRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> findAll(
|
||||||
|
@RequestParam(required = false) Long propertyId) {
|
||||||
|
if (propertyId != null) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findByProperty(propertyId)));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> getPending() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findPending()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/upcoming")
|
||||||
|
public ResponseEntity<ApiResponse<List<ScheduledMaintenance>>> getUpcoming(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findUpcoming(from, to)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ScheduledMaintenance>> findById(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(maintenanceService.findById(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/expense-categories")
|
||||||
|
public ResponseEntity<ApiResponse<List<ExpenseCategory>>> getExpenseCategories() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(expenseCategoryRepository.findAll()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ScheduledMaintenance>> create(@RequestBody ScheduledMaintenance maintenance,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mantenimiento creado",
|
||||||
|
maintenanceService.create(maintenance, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ScheduledMaintenance>> update(@PathVariable Long id,
|
||||||
|
@RequestBody ScheduledMaintenance maintenance) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mantenimiento actualizado",
|
||||||
|
maintenanceService.update(id, maintenance)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/complete")
|
||||||
|
public ResponseEntity<ApiResponse<ScheduledMaintenance>> complete(@PathVariable Long id,
|
||||||
|
@AuthenticationPrincipal SecurityUser user) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mantenimiento completado",
|
||||||
|
maintenanceService.markCompleted(id, user.userId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/reopen")
|
||||||
|
public ResponseEntity<ApiResponse<ScheduledMaintenance>> reopen(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mantenimiento reabierto",
|
||||||
|
maintenanceService.reopen(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(@PathVariable Long id) {
|
||||||
|
maintenanceService.delete(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("Mantenimiento eliminado", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.sapolar.maintenance;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "maintenance_periods")
|
||||||
|
public class MaintenancePeriod {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 30)
|
||||||
|
private String name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.sapolar.maintenance;
|
||||||
|
|
||||||
|
import com.sapolar.common.exception.ResourceNotFoundException;
|
||||||
|
import com.sapolar.finance.expense.ExpenseCategory;
|
||||||
|
import com.sapolar.finance.expense.ExpenseReceipt;
|
||||||
|
import com.sapolar.finance.expense.ExpenseReceiptRepository;
|
||||||
|
import com.sapolar.finance.expense.ExpenseStatus;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.property.PropertyGroupRepository;
|
||||||
|
import com.sapolar.property.PropertyRepository;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MaintenanceService {
|
||||||
|
|
||||||
|
private final ScheduledMaintenanceRepository maintenanceRepository;
|
||||||
|
private final PropertyRepository propertyRepository;
|
||||||
|
private final PropertyGroupRepository propertyGroupRepository;
|
||||||
|
private final ExpenseReceiptRepository expenseReceiptRepository;
|
||||||
|
|
||||||
|
public List<ScheduledMaintenance> findAll() {
|
||||||
|
return maintenanceRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ScheduledMaintenance> findByProperty(Long propertyId) {
|
||||||
|
return maintenanceRepository.findByPropertyId(propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ScheduledMaintenance> findPending() {
|
||||||
|
return maintenanceRepository.findByCompletedFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ScheduledMaintenance> findUpcoming(LocalDate start, LocalDate end) {
|
||||||
|
return maintenanceRepository.findByCompletedFalseAndNextExecutionBetween(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ScheduledMaintenance findById(Long id) {
|
||||||
|
return maintenanceRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Mantenimiento", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduledMaintenance create(ScheduledMaintenance maintenance, Long userId) {
|
||||||
|
// Asociar propiedad o conjunto
|
||||||
|
if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) {
|
||||||
|
Property property = propertyRepository.findById(maintenance.getProperty().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Propiedad", maintenance.getProperty().getId()));
|
||||||
|
maintenance.setProperty(property);
|
||||||
|
}
|
||||||
|
if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup group = propertyGroupRepository.findById(maintenance.getPropertyGroup().getId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Conjunto", maintenance.getPropertyGroup().getId()));
|
||||||
|
maintenance.setPropertyGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
maintenance.setCreatedBy(user);
|
||||||
|
maintenance.setCompleted(false);
|
||||||
|
|
||||||
|
ScheduledMaintenance saved = maintenanceRepository.save(maintenance);
|
||||||
|
|
||||||
|
// Auto-generar gasto si la opción está activa
|
||||||
|
if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) {
|
||||||
|
createExpenseFromMaintenance(saved, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduledMaintenance update(Long id, ScheduledMaintenance updated) {
|
||||||
|
ScheduledMaintenance maintenance = findById(id);
|
||||||
|
maintenance.setTitle(updated.getTitle());
|
||||||
|
maintenance.setDescription(updated.getDescription());
|
||||||
|
maintenance.setEstimatedCost(updated.getEstimatedCost());
|
||||||
|
maintenance.setNextExecution(updated.getNextExecution());
|
||||||
|
maintenance.setReminderDaysBefore(updated.getReminderDaysBefore());
|
||||||
|
maintenance.setResponsible(updated.getResponsible());
|
||||||
|
maintenance.setNotes(updated.getNotes());
|
||||||
|
maintenance.setGenerateExpense(updated.getGenerateExpense());
|
||||||
|
if (updated.getPeriod() != null) maintenance.setPeriod(updated.getPeriod());
|
||||||
|
if (updated.getExpenseCategory() != null) maintenance.setExpenseCategory(updated.getExpenseCategory());
|
||||||
|
return maintenanceRepository.save(maintenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduledMaintenance markCompleted(Long id, Long userId) {
|
||||||
|
ScheduledMaintenance maintenance = findById(id);
|
||||||
|
maintenance.setCompleted(true);
|
||||||
|
maintenance.setCompletedAt(LocalDate.now());
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
maintenance.setCompletedBy(user);
|
||||||
|
|
||||||
|
ScheduledMaintenance saved = maintenanceRepository.save(maintenance);
|
||||||
|
|
||||||
|
// Auto-generar gasto al completar si la opción está activa y es periódico
|
||||||
|
if (Boolean.TRUE.equals(saved.getGenerateExpense()) && saved.getExpenseCategory() != null) {
|
||||||
|
// Para periódicos (no única vez): generar gasto cada vez que se completa
|
||||||
|
if (saved.getPeriod() != null && saved.getPeriod().getId() != 1) {
|
||||||
|
createExpenseFromMaintenance(saved, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ScheduledMaintenance reopen(Long id) {
|
||||||
|
ScheduledMaintenance maintenance = findById(id);
|
||||||
|
maintenance.setCompleted(false);
|
||||||
|
maintenance.setCompletedAt(null);
|
||||||
|
maintenance.setCompletedBy(null);
|
||||||
|
return maintenanceRepository.save(maintenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long id) {
|
||||||
|
maintenanceRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un gasto a partir de los datos de una tarea de mantenimiento.
|
||||||
|
*/
|
||||||
|
private ExpenseReceipt createExpenseFromMaintenance(ScheduledMaintenance maintenance, Long userId) {
|
||||||
|
ExpenseReceipt expense = new ExpenseReceipt();
|
||||||
|
|
||||||
|
// Propiedad o conjunto asociado
|
||||||
|
if (maintenance.getProperty() != null && maintenance.getProperty().getId() != null) {
|
||||||
|
Property propertyRef = new Property();
|
||||||
|
propertyRef.setId(maintenance.getProperty().getId());
|
||||||
|
expense.setProperty(propertyRef);
|
||||||
|
}
|
||||||
|
if (maintenance.getPropertyGroup() != null && maintenance.getPropertyGroup().getId() != null) {
|
||||||
|
PropertyGroup groupRef = new PropertyGroup();
|
||||||
|
groupRef.setId(maintenance.getPropertyGroup().getId());
|
||||||
|
expense.setPropertyGroup(groupRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categoría de gasto
|
||||||
|
expense.setCategory(maintenance.getExpenseCategory());
|
||||||
|
|
||||||
|
// Estado PENDIENTE (id=1)
|
||||||
|
ExpenseStatus status = new ExpenseStatus();
|
||||||
|
status.setId(1);
|
||||||
|
expense.setStatus(status);
|
||||||
|
|
||||||
|
// Importes
|
||||||
|
BigDecimal amount = maintenance.getEstimatedCost() != null
|
||||||
|
? maintenance.getEstimatedCost()
|
||||||
|
: BigDecimal.ZERO;
|
||||||
|
expense.setAmount(amount);
|
||||||
|
expense.setTaxAmount(BigDecimal.ZERO);
|
||||||
|
expense.setTotalAmount(amount);
|
||||||
|
|
||||||
|
// Fechas
|
||||||
|
expense.setIssueDate(LocalDate.now());
|
||||||
|
if (maintenance.getNextExecution() != null) {
|
||||||
|
expense.setDueDate(maintenance.getNextExecution());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descripción
|
||||||
|
String desc = "Mantenimiento: " + maintenance.getTitle();
|
||||||
|
if (maintenance.getProperty() != null && maintenance.getProperty().getName() != null) {
|
||||||
|
desc += " - " + maintenance.getProperty().getName();
|
||||||
|
}
|
||||||
|
expense.setDescription(desc);
|
||||||
|
|
||||||
|
// Responsable como proveedor
|
||||||
|
expense.setSupplierName(maintenance.getResponsible());
|
||||||
|
|
||||||
|
// Creador
|
||||||
|
User user = new User();
|
||||||
|
user.setId(userId);
|
||||||
|
expense.setCreatedBy(user);
|
||||||
|
|
||||||
|
return expenseReceiptRepository.save(expense);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.sapolar.maintenance;
|
||||||
|
|
||||||
|
import com.sapolar.finance.expense.ExpenseCategory;
|
||||||
|
import com.sapolar.property.Property;
|
||||||
|
import com.sapolar.property.PropertyGroup;
|
||||||
|
import com.sapolar.user.User;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "scheduled_maintenance", indexes = {
|
||||||
|
@Index(name = "idx_maint_property", columnList = "property_id"),
|
||||||
|
@Index(name = "idx_maint_next_exec", columnList = "next_execution"),
|
||||||
|
@Index(name = "idx_maint_completed", columnList = "completed")
|
||||||
|
})
|
||||||
|
public class ScheduledMaintenance {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_id")
|
||||||
|
private Property property;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "property_group_id")
|
||||||
|
private PropertyGroup propertyGroup;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "period_id", nullable = false)
|
||||||
|
private MaintenancePeriod period;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "estimated_cost", precision = 12, scale = 2)
|
||||||
|
private BigDecimal estimatedCost;
|
||||||
|
|
||||||
|
@Column(name = "last_execution")
|
||||||
|
private LocalDate lastExecution;
|
||||||
|
|
||||||
|
@Column(name = "next_execution", nullable = false)
|
||||||
|
private LocalDate nextExecution;
|
||||||
|
|
||||||
|
@Column(name = "reminder_days_before", nullable = false)
|
||||||
|
private Integer reminderDaysBefore = 30;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String responsible;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
@Column(name = "generate_expense", nullable = false)
|
||||||
|
private Boolean generateExpense = false;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "expense_category_id")
|
||||||
|
private ExpenseCategory expenseCategory;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean completed = false;
|
||||||
|
|
||||||
|
@Column(name = "completed_at")
|
||||||
|
private LocalDate completedAt;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "completed_by")
|
||||||
|
private User completedBy;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private User createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user