feat: otimização de performance e ajustes finais

This commit is contained in:
Idrissa Banora
2026-05-18 10:49:32 +00:00
commit 52a7c4f9cf
579 changed files with 156489 additions and 0 deletions
@@ -0,0 +1,63 @@
<?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
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>sigefp-budget</artifactId>
<packaging>jar</packaging>
<name>SIGEFP Budget</name>
<description>Módulo de orçamento: exercícios, linhas orçamentais, execução</description>
<dependencies>
<dependency>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>br.gov.sigefp</groupId>
<artifactId>sigefp-org</artifactId>
<version>${project.version}</version>
</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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,60 @@
package br.gov.sigefp.budget.api;
import br.gov.sigefp.budget.api.dto.BudgetEntryDTO;
import br.gov.sigefp.budget.api.dto.CreateBudgetEntryDTO;
import br.gov.sigefp.budget.service.BudgetEntryService;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/budget/entries")
@RequiredArgsConstructor
@Slf4j
public class BudgetEntryController {
private final BudgetEntryService budgetEntryService;
@PostMapping
public ResponseEntity<BudgetEntryDTO> create(@Valid @RequestBody CreateBudgetEntryDTO dto) {
try {
BudgetEntryDTO created = budgetEntryService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (ResourceNotFoundException e) {
log.warn("Recurso não encontrado ao criar entrada orçamentária: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
} catch (BusinessException e) {
log.warn("Erro de negócio ao criar entrada orçamentária: {}", e.getMessage());
return ResponseEntity.status(e.getStatus()).build();
} catch (Exception e) {
log.error("Erro inesperado ao criar entrada orçamentária", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping
public ResponseEntity<Page<BudgetEntryDTO>> findByBudgetLine(
@RequestParam(name = "budgetLineId") UUID budgetLineId,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", defaultValue = "transactionDate") String sortBy,
@RequestParam(name = "sortDirection", defaultValue = "DESC") String sortDirection) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<BudgetEntryDTO> result = budgetEntryService.findByBudgetLineId(budgetLineId, pageable);
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,73 @@
package br.gov.sigefp.budget.api;
import br.gov.sigefp.budget.api.dto.BudgetExecutionDTO;
import br.gov.sigefp.budget.service.BudgetExecutionService;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.InsufficientBudgetException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* Controller REST para gestão de execuções orçamentárias.
*/
@RestController
@RequestMapping("/api/budget/execution")
@RequiredArgsConstructor
@Slf4j
public class BudgetExecutionController {
private final BudgetExecutionService budgetExecutionService;
@GetMapping
public ResponseEntity<?> findAll(
@RequestParam(name = "budgetLineId", required = false) UUID budgetLineId,
@RequestParam(name = "periodId", required = false) Long periodId,
@RequestParam(name = "movementType", required = false) String movementType,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "DESC") String sortDirection) {
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.DESC, "createdAt");
Pageable pageable = PageRequest.of(page, size, sort);
Page<BudgetExecutionDTO> result = budgetExecutionService.findByFilters(
budgetLineId, periodId, movementType, pageable);
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<BudgetExecutionDTO> registerExecution(@Valid @RequestBody BudgetExecutionDTO dto) {
try {
BudgetExecutionDTO created = budgetExecutionService.registerExecution(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (ResourceNotFoundException e) {
log.warn("Recurso não encontrado ao registrar execução: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
} catch (InsufficientBudgetException e) {
log.warn("Saldo insuficiente ao registrar execução: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).build();
} catch (BusinessException e) {
log.warn("Erro de negócio ao registrar execução: {}", e.getMessage());
return ResponseEntity.status(e.getStatus()).build();
} catch (Exception e) {
log.error("Erro inesperado ao registrar execução", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,99 @@
package br.gov.sigefp.budget.api;
import br.gov.sigefp.budget.api.dto.BudgetLineDTO;
import br.gov.sigefp.budget.service.BudgetLineService;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* Controller REST para gestão de linhas orçamentárias.
*/
@RestController
@RequestMapping("/api/budget/lines")
@RequiredArgsConstructor
@Slf4j
public class BudgetLineController {
private final BudgetLineService budgetLineService;
@GetMapping
public ResponseEntity<Page<BudgetLineDTO>> findAll(
@RequestParam(name = "fiscalYearId", required = false) UUID fiscalYearId,
@RequestParam(name = "ministryId", required = false) UUID ministryId,
@RequestParam(name = "orgUnitId", required = false) UUID orgUnitId,
@RequestParam(name = "search", required = false) String search,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "ASC") String sortDirection) {
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.ASC, "code");
Pageable pageable = PageRequest.of(page, size, sort);
Page<BudgetLineDTO> result = budgetLineService.findWithFilters(fiscalYearId, ministryId, orgUnitId, search,
pageable);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<BudgetLineDTO> findById(@PathVariable UUID id) {
try {
BudgetLineDTO dto = budgetLineService.findById(id);
return ResponseEntity.ok(dto);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Recurso não encontrado ao buscar linha orçamentária: {}", id);
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erro inesperado ao buscar linha orçamentária", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping
public ResponseEntity<BudgetLineDTO> create(@Valid @RequestBody BudgetLineDTO dto) {
try {
BudgetLineDTO created = budgetLineService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (BusinessException | IllegalArgumentException e) {
log.warn("Erro de negócio ao criar linha orçamentária: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Erro inesperado ao criar linha orçamentária", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PutMapping("/{id}")
public ResponseEntity<BudgetLineDTO> update(
@PathVariable UUID id,
@Valid @RequestBody BudgetLineDTO dto) {
try {
BudgetLineDTO updated = budgetLineService.update(id, dto);
return ResponseEntity.ok(updated);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Recurso não encontrado ao atualizar linha orçamentária: {}", id);
return ResponseEntity.notFound().build();
} catch (BusinessException e) {
log.warn("Erro de negócio ao atualizar linha orçamentária: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Erro inesperado ao atualizar linha orçamentária", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,23 @@
package br.gov.sigefp.budget.api;
import br.gov.sigefp.budget.api.dto.EconomicClassificationDTO;
import br.gov.sigefp.budget.service.EconomicClassificationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/budget/economic-classifications")
@RequiredArgsConstructor
public class EconomicClassificationController {
private final EconomicClassificationService service;
@GetMapping
public ResponseEntity<List<EconomicClassificationDTO>> findAll(
@RequestParam(required = false) String type) {
return ResponseEntity.ok(service.findAll(type));
}
}
@@ -0,0 +1,122 @@
package br.gov.sigefp.budget.api;
import br.gov.sigefp.budget.api.dto.FiscalYearDTO;
import br.gov.sigefp.budget.service.FiscalYearService;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* Controller REST para gestão de anos fiscais.
*/
@RestController
@RequestMapping("/api/budget/fiscal-years")
@RequiredArgsConstructor
@Slf4j
public class FiscalYearController {
private final FiscalYearService fiscalYearService;
@GetMapping
public ResponseEntity<Page<FiscalYearDTO>> findAll(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "20") int size,
@RequestParam(name = "sortBy", required = false) String sortBy,
@RequestParam(name = "sortDirection", required = false, defaultValue = "DESC") String sortDirection) {
Sort sort = sortBy != null
? Sort.by(Sort.Direction.fromString(sortDirection), sortBy)
: Sort.by(Sort.Direction.DESC, "year");
Pageable pageable = PageRequest.of(page, size, sort);
Page<FiscalYearDTO> result = fiscalYearService.findAll(pageable);
return ResponseEntity.ok(result);
}
@GetMapping("/current")
public ResponseEntity<FiscalYearDTO> getCurrent() {
try {
FiscalYearDTO dto = fiscalYearService.getCurrentFiscalYear();
return ResponseEntity.ok(dto);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Ano fiscal corrente não encontrado");
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erro inesperado ao buscar ano fiscal corrente", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/{id}")
public ResponseEntity<FiscalYearDTO> findById(@PathVariable(name = "id") UUID id) {
try {
FiscalYearDTO dto = fiscalYearService.findById(id);
return ResponseEntity.ok(dto);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Ano fiscal não encontrado: {}", id);
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erro inesperado ao buscar ano fiscal", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping
public ResponseEntity<FiscalYearDTO> create(@Valid @RequestBody FiscalYearDTO dto) {
try {
FiscalYearDTO created = fiscalYearService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
} catch (BusinessException | IllegalArgumentException e) {
log.warn("Erro de negócio ao criar ano fiscal: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Erro inesperado ao criar ano fiscal", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/{id}/open")
public ResponseEntity<FiscalYearDTO> open(@PathVariable(name = "id") UUID id) {
try {
FiscalYearDTO updated = fiscalYearService.open(id);
return ResponseEntity.ok(updated);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Ano fiscal não encontrado ao abrir: {}", id);
return ResponseEntity.notFound().build();
} catch (BusinessException e) {
log.warn("Erro de negócio ao abrir ano fiscal: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Erro inesperado ao abrir ano fiscal", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/{id}/close")
public ResponseEntity<FiscalYearDTO> close(@PathVariable(name = "id") UUID id) {
try {
FiscalYearDTO updated = fiscalYearService.close(id);
return ResponseEntity.ok(updated);
} catch (ResourceNotFoundException | IllegalArgumentException e) {
log.warn("Ano fiscal não encontrado ao fechar: {}", id);
return ResponseEntity.notFound().build();
} catch (BusinessException e) {
log.warn("Erro de negócio ao fechar ano fiscal: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (Exception e) {
log.error("Erro inesperado ao fechar ano fiscal", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
@@ -0,0 +1,24 @@
package br.gov.sigefp.budget.api.dto;
import br.gov.sigefp.budget.domain.BudgetEntryType;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Data
@Builder
public class BudgetEntryDTO {
private UUID id;
private UUID budgetLineId;
private BudgetEntryType type;
private BigDecimal amount;
private LocalDate transactionDate;
private String documentReference;
private String description;
}
@@ -0,0 +1,51 @@
package br.gov.sigefp.budget.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO para transferência de dados de execução orçamentária.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BudgetExecutionDTO {
private UUID id;
@NotNull(message = "ID da linha orçamentária é obrigatório")
private UUID budgetLineId;
private Long periodId;
@NotBlank(message = "Tipo de movimento é obrigatório")
@Size(max = 50, message = "Tipo de movimento deve ter no máximo 50 caracteres")
private String movementType; // COMMITMENT, LIQUIDATION, PAYMENT
@NotNull(message = "Valor é obrigatório")
@Positive(message = "Valor deve ser positivo")
private BigDecimal amount;
private LocalDateTime createdAt;
@NotBlank(message = "Módulo de origem é obrigatório")
@Size(max = 50, message = "Módulo de origem deve ter no máximo 50 caracteres")
private String sourceModule;
private UUID referenceId;
// Campos enriquecidos para exibição
private String budgetLineCode;
private String description;
}
@@ -0,0 +1,50 @@
package br.gov.sigefp.budget.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.UUID;
/**
* DTO para transferência de dados de linha orçamentária.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BudgetLineDTO {
private UUID id;
@NotNull(message = "ID do ano fiscal é obrigatório")
private UUID fiscalYearId;
@NotBlank(message = "Código é obrigatório")
@Size(min = 1, max = 50, message = "Código deve ter entre 1 e 50 caracteres")
private String code;
@NotBlank(message = "Descrição é obrigatória")
@Size(max = 500, message = "Descrição deve ter no máximo 500 caracteres")
private String description;
@NotNull(message = "ID do ministério é obrigatório")
private UUID ministryId;
@NotNull(message = "ID da unidade organizacional é obrigatório")
private UUID orgUnitId;
@NotBlank(message = "Classificação econômica é obrigatória")
@Size(max = 100, message = "Classificação econômica deve ter no máximo 100 caracteres")
private String economicClass;
// Campos calculados
private BigDecimal totalAllocated;
private BigDecimal totalCommitted;
private BigDecimal availableBalance;
}
@@ -0,0 +1,32 @@
package br.gov.sigefp.budget.api.dto;
import br.gov.sigefp.budget.domain.BudgetEntryType;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Data
public class CreateBudgetEntryDTO {
@NotNull(message = "Linha orçamentária é obrigatória")
private UUID budgetLineId;
@NotNull(message = "Tipo de movimento é obrigatório")
private BudgetEntryType type;
@NotNull(message = "Valor é obrigatório")
@DecimalMin(value = "0.01", message = "Valor deve ser maior que zero")
private BigDecimal amount;
@NotNull(message = "Data da transação é obrigatória")
private LocalDate transactionDate;
@NotBlank(message = "Referência documental é obrigatória (ex: Lei, Decreto)")
private String documentReference;
private String description;
}
@@ -0,0 +1,25 @@
package br.gov.sigefp.budget.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EconomicClassificationDTO {
private UUID id;
private String code;
private String description;
private String type;
private String uemoaCode;
// Helper for displaying "Code - Description"
public String getLabel() {
return code + " - " + description;
}
}
@@ -0,0 +1,40 @@
package br.gov.sigefp.budget.api.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.UUID;
/**
* DTO para transferência de dados de ano fiscal.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FiscalYearDTO {
private UUID id;
@NotNull(message = "Ano é obrigatório")
@Min(value = 2000, message = "Ano deve ser maior ou igual a 2000")
@Max(value = 2100, message = "Ano deve ser menor ou igual a 2100")
private Integer year;
@NotNull(message = "Data de início é obrigatória")
private LocalDate startDate;
@NotNull(message = "Data de fim é obrigatória")
private LocalDate endDate;
@NotBlank(message = "Status é obrigatório")
private String status; // DRAFT, OPEN, CLOSED
}
@@ -0,0 +1,36 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* Entidade que representa uma alocação orçamentária.
* Usa BigDecimal para valores financeiros.
*/
@Entity
@Table(name = "budget_allocation", indexes = {
@Index(name = "idx_budget_allocation_line", columnList = "budget_line_id"),
@Index(name = "idx_budget_allocation_created", columnList = "created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BudgetAllocation extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "budget_line_id", nullable = false)
private BudgetLine budgetLine;
@Column(nullable = false, precision = 19, scale = 2, name = "initial_amount")
private BigDecimal initialAmount; // Valor inicial alocado
@Column(nullable = false, precision = 19, scale = 2, name = "adjustment_amount")
@Builder.Default
private BigDecimal adjustmentAmount = BigDecimal.ZERO; // Ajustes (positivos ou negativos)
}
@@ -0,0 +1,42 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "budget_entry", indexes = {
@Index(name = "idx_budget_entry_line", columnList = "budget_line_id"),
@Index(name = "idx_budget_entry_date", columnList = "transaction_date")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class BudgetEntry extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "budget_line_id", nullable = false)
private BudgetLine budgetLine;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 30)
private BudgetEntryType type;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate; // Data da Lei/Decreto
@Column(nullable = false, length = 100, name = "document_reference")
private String documentReference; // Ex: "Lei do Orçamento 2024", "Decreto 12/2024"
@Column(length = 255)
private String description;
}
@@ -0,0 +1,10 @@
package br.gov.sigefp.budget.domain;
public enum BudgetEntryType {
INITIAL_ALLOCATION, // Dotação Inicial (Lei do Orçamento)
SUPPLEMENTARY_CREDIT, // Crédito Suplementar
SPECIAL_CREDIT, // Crédito Especial
CANCELLATION, // Anulação de Dotação
TRANSFER_IN, // Transferência Recebida
TRANSFER_OUT // Transferência Enviada
}
@@ -0,0 +1,49 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Entidade que representa uma execução orçamentária.
* Usa BigDecimal para valores financeiros.
* Usa periodId (Long) para referência ao período de folha, desacoplando do módulo RH.
*/
@Entity
@Table(name = "budget_execution", indexes = {
@Index(name = "idx_budget_execution_line", columnList = "budget_line_id"),
@Index(name = "idx_budget_execution_period", columnList = "period_id"),
@Index(name = "idx_budget_execution_created", columnList = "created_at"),
@Index(name = "idx_budget_execution_source", columnList = "source_module,reference_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class BudgetExecution extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "budget_line_id", nullable = false)
private BudgetLine budgetLine;
@Column(name = "period_id")
private Long periodId; // Referência ao período de folha (rh.payroll_period) - desacoplado
@Column(nullable = false, length = 50, name = "movement_type")
private String movementType; // COMMITMENT, PAYMENT, REVERSAL - preparado para enum
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Column(nullable = false, length = 50, name = "source_module")
private String sourceModule; // rh, treasury, etc. - módulo que originou a execução
@Column(name = "reference_id")
private UUID referenceId; // ID da entidade no módulo de origem (ex: payment_id, payroll_item_id)
}
@@ -0,0 +1,51 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.util.UUID;
/**
* Entidade que representa uma linha orçamentária.
*/
@Entity
@Table(name = "budget_line", indexes = {
@Index(name = "idx_budget_line_code", columnList = "code"),
@Index(name = "idx_budget_line_fiscal_year", columnList = "fiscal_year_id"),
@Index(name = "idx_budget_line_ministry", columnList = "ministry_id"),
@Index(name = "idx_budget_line_org_unit", columnList = "org_unit_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class BudgetLine extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "fiscal_year_id", nullable = false)
private FiscalYear fiscalYear;
@Column(nullable = false, unique = true, length = 50)
private String code;
@Column(nullable = false, length = 500)
private String description;
@Column(name = "ministry_id", nullable = false)
private UUID ministry; // Referência ao ministério (org.ministry)
@Column(name = "org_unit_id", nullable = false)
private UUID orgUnit; // Referência à unidade organizacional (org.org_unit)
@Column(nullable = false, length = 100, name = "economic_class")
private String economicClass; // Classificação econômica
@org.hibernate.annotations.Formula("(SELECT COALESCE(SUM(be.amount), 0) FROM budget_entry be WHERE be.budget_line_id = id)")
private java.math.BigDecimal totalAllocated;
@org.hibernate.annotations.Formula("(SELECT COALESCE(SUM(bex.amount), 0) FROM budget_execution bex WHERE bex.budget_line_id = id AND bex.movement_type = 'COMMITMENT')")
private java.math.BigDecimal totalCommitted;
}
@@ -0,0 +1,30 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.*;
import lombok.experimental.SuperBuilder;
@Entity
@Table(name = "economic_classification")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class EconomicClassification extends BaseEntity {
@Column(nullable = false, unique = true, length = 20)
private String code; // e.g. 6111
@Column(nullable = false, length = 255)
private String description; // e.g. Salários do pessoal do quadro
@Column(nullable = false, length = 20)
private String type; // REVENUE (Receita) or EXPENSE (Despesa)
@Column(length = 20)
private String uemoaCode; // Optional mapping (e.g. 010101 for 7111)
}
@@ -0,0 +1,38 @@
package br.gov.sigefp.budget.domain;
import br.gov.sigefp.common.domain.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;
import java.time.LocalDate;
/**
* Entidade que representa um ano fiscal.
* Usa LocalDate para datas de início e fim.
*/
@Entity
@Table(name = "fiscal_year", indexes = {
@Index(name = "idx_fiscal_year_year", columnList = "year"),
@Index(name = "idx_fiscal_year_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
public class FiscalYear extends BaseEntity {
@Column(nullable = false, unique = true)
private Integer year;
@Column(nullable = false, name = "start_date")
private LocalDate startDate;
@Column(nullable = false, name = "end_date")
private LocalDate endDate;
@Column(nullable = false, length = 20)
@Builder.Default
private String status = "OPEN"; // OPEN, CLOSED, PLANNING - preparado para enum
}
@@ -0,0 +1,139 @@
package br.gov.sigefp.budget.integration;
import br.gov.sigefp.budget.api.dto.BudgetExecutionDTO;
import br.gov.sigefp.budget.service.BudgetExecutionService;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.InsufficientBudgetException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Serviço de integração para criar execuções orçamentárias a partir de outros
* módulos.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BudgetIntegrationService {
private final BudgetExecutionService budgetExecutionService;
/**
* Cria execução orçamentária do tipo COMMITMENT a partir de um item de folha.
*/
@Transactional
public void createCommitmentFromPayrollItem(UUID budgetLineId, Long periodId, BigDecimal amount, UUID referenceId) {
if (budgetLineId == null || periodId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
try {
BudgetExecutionDTO execution = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.periodId(periodId)
.movementType("COMMITMENT")
.amount(amount)
.sourceModule("rh")
.referenceId(referenceId)
.build();
budgetExecutionService.registerExecution(execution);
} catch (InsufficientBudgetException e) {
log.error("Saldo insuficiente ao criar execução orçamentária (COMMITMENT)", e);
throw e; // Re-throw exceção específica
} catch (BusinessException e) {
log.error("Erro de negócio ao criar execução orçamentária (COMMITMENT)", e);
throw e; // Re-throw exceção específica
} catch (Exception e) {
log.error("Erro inesperado ao criar execução orçamentária (COMMITMENT)", e);
throw new BusinessException("Erro ao criar execução orçamentária (COMMITMENT)",
"EXECUTION_ERROR", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Cria execução orçamentária do tipo PAYMENT a partir de um pagamento da
* tesouraria.
*/
@Transactional
public void createPaymentFromTreasury(UUID budgetLineId, Long periodId, BigDecimal amount, UUID referenceId) {
if (budgetLineId == null || periodId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
try {
BudgetExecutionDTO execution = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.periodId(periodId)
.movementType("PAYMENT")
.amount(amount)
.sourceModule("treasury")
.referenceId(referenceId)
.build();
budgetExecutionService.registerExecution(execution);
} catch (InsufficientBudgetException e) {
log.error("Saldo insuficiente ao criar execução orçamentária (PAYMENT)", e);
throw e;
} catch (BusinessException e) {
log.error("Erro de negócio ao criar execução orçamentária (PAYMENT)", e);
throw e;
} catch (Exception e) {
log.error("Erro inesperado ao criar execução orçamentária (PAYMENT)", e);
throw new BusinessException("Erro ao criar execução orçamentária (PAYMENT)",
"EXECUTION_ERROR", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Cria execução orçamentária do tipo LIQUIDATION a partir de um item de folha
* (no encerramento).
*/
@Transactional
public void createLiquidationFromPayrollItem(UUID budgetLineId, Long periodId, BigDecimal amount,
UUID referenceId) {
if (budgetLineId == null || periodId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
try {
BudgetExecutionDTO execution = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.periodId(periodId)
.movementType("LIQUIDATION")
.amount(amount)
.sourceModule("rh")
.referenceId(referenceId)
.build();
budgetExecutionService.registerExecution(execution);
} catch (InsufficientBudgetException e) {
log.error("Saldo insuficiente ao criar execução orçamentária (LIQUIDATION)", e);
throw e;
} catch (BusinessException e) {
log.error("Erro de negócio ao criar execução orçamentária (LIQUIDATION)", e);
throw e;
} catch (Exception e) {
log.error("Erro inesperado ao criar execução orçamentária (LIQUIDATION)", e);
throw new BusinessException("Erro ao criar execução orçamentária (LIQUIDATION)",
"EXECUTION_ERROR", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/**
* Converte um PayrollPeriod (fiscalYear + month) para um periodId Long (formato
* YYYYMM).
*/
public static Long toPeriodId(Integer fiscalYear, Integer month) {
if (fiscalYear == null || month == null) {
return null;
}
return Long.valueOf(fiscalYear * 100 + month);
}
}
@@ -0,0 +1,25 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.BudgetAllocation;
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.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface BudgetAllocationRepository extends JpaRepository<BudgetAllocation, UUID> {
List<BudgetAllocation> findByBudgetLineId(UUID budgetLineId);
@Query("SELECT ba FROM BudgetAllocation ba WHERE ba.budgetLine.id = :budgetLineId ORDER BY ba.createdAt DESC")
Optional<BudgetAllocation> findLatestByBudgetLineId(@Param("budgetLineId") UUID budgetLineId);
@Query("SELECT COALESCE(SUM(ba.initialAmount + ba.adjustmentAmount), 0) FROM BudgetAllocation ba WHERE ba.budgetLine.id = :budgetLineId")
BigDecimal calculateTotalAllocatedByBudgetLineId(@Param("budgetLineId") UUID budgetLineId);
}
@@ -0,0 +1,14 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.BudgetEntry;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface BudgetEntryRepository extends JpaRepository<BudgetEntry, UUID> {
Page<BudgetEntry> findByBudgetLineId(UUID budgetLineId, Pageable pageable);
}
@@ -0,0 +1,66 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.BudgetExecution;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.util.List;
import java.util.UUID;
@Repository
public interface BudgetExecutionRepository extends JpaRepository<BudgetExecution, UUID>,
org.springframework.data.jpa.repository.JpaSpecificationExecutor<BudgetExecution> {
List<BudgetExecution> findByBudgetLineId(UUID budgetLineId);
@Query("SELECT be FROM BudgetExecution be WHERE be.budgetLine.id = :budgetLineId AND be.movementType = :movementType")
List<BudgetExecution> findByBudgetLineIdAndMovementType(@Param("budgetLineId") UUID budgetLineId,
@Param("movementType") String movementType);
@Query("SELECT be FROM BudgetExecution be WHERE be.periodId = :periodId")
List<BudgetExecution> findByPeriodId(@Param("periodId") Long periodId);
@Query("SELECT COALESCE(SUM(be.amount), 0) FROM BudgetExecution be WHERE be.budgetLine.id = :budgetLineId AND be.movementType = 'COMMITMENT'")
BigDecimal calculateTotalCommittedByBudgetLineId(@Param("budgetLineId") UUID budgetLineId);
@Query("SELECT be FROM BudgetExecution be WHERE be.budgetLine.fiscalYear.id = :fiscalYearId")
List<BudgetExecution> findExecutionsByFiscalYearId(@Param("fiscalYearId") UUID fiscalYearId);
@Query("SELECT be FROM BudgetExecution be WHERE " +
"(:budgetLineId IS NULL OR be.budgetLine.id = :budgetLineId) AND " +
"(:periodId IS NULL OR be.periodId = :periodId) AND " +
"(:movementType IS NULL OR be.movementType = :movementType)")
Page<BudgetExecution> findByFilters(
@Param("budgetLineId") UUID budgetLineId,
@Param("periodId") Long periodId,
@Param("movementType") String movementType,
Pageable pageable);
// Helper para listas sem paginação (exportação)
@Query("SELECT be FROM BudgetExecution be WHERE " +
"(:budgetLineId IS NULL OR be.budgetLine.id = :budgetLineId) AND " +
"(:periodId IS NULL OR be.periodId = :periodId) AND " +
"(:movementType IS NULL OR be.movementType = :movementType)")
List<BudgetExecution> findByFiltersList(
@Param("budgetLineId") UUID budgetLineId,
@Param("periodId") Long periodId,
@Param("movementType") String movementType);
@Query("SELECT COUNT(be) FROM BudgetExecution be WHERE be.budgetLine.fiscalYear.id = :fiscalYearId AND be.movementType = :movementType")
long countByFiscalYearIdAndMovementType(@Param("fiscalYearId") UUID fiscalYearId,
@Param("movementType") String movementType);
@Query("SELECT COALESCE(SUM(be.amount), 0) FROM BudgetExecution be WHERE be.referenceId = :referenceId AND be.movementType = 'COMMITMENT'")
BigDecimal calculateTotalCommittedByReferenceId(@Param("referenceId") UUID referenceId);
@Query("SELECT COALESCE(SUM(be.amount), 0) FROM BudgetExecution be WHERE be.referenceId = :referenceId AND be.movementType = 'LIQUIDATION'")
BigDecimal calculateTotalLiquidatedByReferenceId(@Param("referenceId") UUID referenceId);
@Query("SELECT COALESCE(SUM(be.amount), 0) FROM BudgetExecution be WHERE be.referenceId = :referenceId AND be.movementType = 'PAYMENT'")
BigDecimal calculateTotalPaidByReferenceId(@Param("referenceId") UUID referenceId);
}
@@ -0,0 +1,37 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.BudgetLine;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
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;
import java.util.UUID;
@Repository
public interface BudgetLineRepository extends JpaRepository<BudgetLine, UUID>, JpaSpecificationExecutor<BudgetLine> {
Optional<BudgetLine> findByCode(String code);
boolean existsByCode(String code);
@Query("SELECT bl FROM BudgetLine bl WHERE bl.fiscalYear.id = :fiscalYearId AND bl.code = :code")
Optional<BudgetLine> findByFiscalYearIdAndCode(@Param("fiscalYearId") UUID fiscalYearId,
@Param("code") String code);
@Query("SELECT COUNT(bl) > 0 FROM BudgetLine bl WHERE bl.fiscalYear.id = :fiscalYearId AND bl.code = :code")
boolean existsByFiscalYearIdAndCode(@Param("fiscalYearId") UUID fiscalYearId, @Param("code") String code);
List<BudgetLine> findByFiscalYearId(UUID fiscalYearId);
@Query("SELECT bl FROM BudgetLine bl WHERE bl.fiscalYear.id = :fiscalYearId AND bl.ministry = :ministryId")
List<BudgetLine> findByFiscalYearIdAndMinistryId(@Param("fiscalYearId") UUID fiscalYearId,
@Param("ministryId") UUID ministryId);
@Query("SELECT bl FROM BudgetLine bl WHERE bl.fiscalYear.id = :fiscalYearId AND bl.orgUnit = :orgUnitId")
List<BudgetLine> findByFiscalYearIdAndOrgUnitId(@Param("fiscalYearId") UUID fiscalYearId,
@Param("orgUnitId") UUID orgUnitId);
}
@@ -0,0 +1,15 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.EconomicClassification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface EconomicClassificationRepository
extends JpaRepository<EconomicClassification, UUID>, JpaSpecificationExecutor<EconomicClassification> {
Optional<EconomicClassification> findByCode(String code);
}
@@ -0,0 +1,21 @@
package br.gov.sigefp.budget.repository;
import br.gov.sigefp.budget.domain.FiscalYear;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface FiscalYearRepository extends JpaRepository<FiscalYear, UUID> {
Optional<FiscalYear> findByYear(Integer year);
boolean existsByYear(Integer year);
@Query("SELECT fy FROM FiscalYear fy WHERE fy.status = 'OPEN' ORDER BY fy.year DESC")
Optional<FiscalYear> findCurrentFiscalYear();
}
@@ -0,0 +1,125 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.BudgetEntryDTO;
import br.gov.sigefp.budget.api.dto.CreateBudgetEntryDTO;
import br.gov.sigefp.budget.domain.BudgetAllocation;
import br.gov.sigefp.budget.domain.BudgetEntry;
import br.gov.sigefp.budget.domain.BudgetLine;
import br.gov.sigefp.budget.domain.BudgetEntryType;
import br.gov.sigefp.budget.repository.BudgetAllocationRepository;
import br.gov.sigefp.budget.repository.BudgetEntryRepository;
import br.gov.sigefp.budget.repository.BudgetLineRepository;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Transactional
public class BudgetEntryService {
private final BudgetEntryRepository budgetEntryRepository;
private final BudgetAllocationRepository budgetAllocationRepository;
private final BudgetLineRepository budgetLineRepository;
private final BudgetExecutionRepository budgetExecutionRepository;
public BudgetEntryDTO create(CreateBudgetEntryDTO dto) {
BudgetLine budgetLine = budgetLineRepository.findById(dto.getBudgetLineId())
.orElseThrow(() -> new ResourceNotFoundException(
"Linha orçamentária não encontrada: " + dto.getBudgetLineId()));
// Validação: Data da transação deve estar dentro do exercício fiscal
if (dto.getTransactionDate().isBefore(budgetLine.getFiscalYear().getStartDate()) ||
dto.getTransactionDate().isAfter(budgetLine.getFiscalYear().getEndDate())) {
throw new BusinessException(
String.format("Data da transação (%s) deve estar dentro do exercício fiscal (%s a %s)",
dto.getTransactionDate(),
budgetLine.getFiscalYear().getStartDate(),
budgetLine.getFiscalYear().getEndDate()),
"INVALID_TRANSACTION_DATE", HttpStatus.BAD_REQUEST);
}
// Validação: TRANSFER_OUT e CANCELLATION não podem exceder saldo disponível
if (dto.getType() == BudgetEntryType.TRANSFER_OUT || dto.getType() == BudgetEntryType.CANCELLATION) {
BigDecimal totalAllocated = budgetAllocationRepository
.calculateTotalAllocatedByBudgetLineId(budgetLine.getId());
BigDecimal totalCommitted = budgetExecutionRepository
.calculateTotalCommittedByBudgetLineId(budgetLine.getId());
BigDecimal available = totalAllocated.subtract(totalCommitted);
if (dto.getAmount().compareTo(available) > 0) {
throw new BusinessException(
String.format("Transferência/Cancelamento não pode exceder saldo disponível. Disponível: %s, Solicitado: %s",
available, dto.getAmount()),
"INSUFFICIENT_BALANCE", HttpStatus.CONFLICT);
}
}
// 1. Create the Audit Entry
BudgetEntry entry = BudgetEntry.builder()
.budgetLine(budgetLine)
.type(dto.getType())
.amount(dto.getAmount())
.transactionDate(dto.getTransactionDate())
.documentReference(dto.getDocumentReference())
.description(dto.getDescription())
.build();
BudgetEntry savedEntry = budgetEntryRepository.save(entry);
// 2. Reflect impact in BudgetAllocation
// We create a NEW allocation record for each entry to keep the sum correct
// according to the repository's logic: SUM(initialAmount + adjustmentAmount)
BigDecimal initialAmount = BigDecimal.ZERO;
BigDecimal adjustmentAmount = BigDecimal.ZERO;
if (dto.getType() == BudgetEntryType.INITIAL_ALLOCATION) {
initialAmount = dto.getAmount();
} else {
// For other types, it's an adjustment
// Check if it adds or subtracts
if (dto.getType() == BudgetEntryType.CANCELLATION || dto.getType() == BudgetEntryType.TRANSFER_OUT) {
adjustmentAmount = dto.getAmount().negate();
} else {
adjustmentAmount = dto.getAmount();
}
}
BudgetAllocation allocation = BudgetAllocation.builder()
.budgetLine(budgetLine)
.initialAmount(initialAmount)
.adjustmentAmount(adjustmentAmount)
.build();
budgetAllocationRepository.save(allocation);
return toDTO(savedEntry);
}
@Transactional(readOnly = true)
public Page<BudgetEntryDTO> findByBudgetLineId(UUID budgetLineId, Pageable pageable) {
return budgetEntryRepository.findByBudgetLineId(budgetLineId, pageable)
.map(this::toDTO);
}
private BudgetEntryDTO toDTO(BudgetEntry entry) {
return BudgetEntryDTO.builder()
.id(entry.getId())
.budgetLineId(entry.getBudgetLine().getId())
.type(entry.getType())
.amount(entry.getAmount())
.transactionDate(entry.getTransactionDate())
.documentReference(entry.getDocumentReference())
.description(entry.getDescription())
.build();
}
}
@@ -0,0 +1,260 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.BudgetExecutionDTO;
import br.gov.sigefp.budget.domain.BudgetExecution;
import br.gov.sigefp.budget.domain.BudgetLine;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.budget.repository.BudgetLineRepository;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.InsufficientBudgetException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
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.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de execuções orçamentárias.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class BudgetExecutionService {
private final BudgetExecutionRepository budgetExecutionRepository;
private final BudgetLineRepository budgetLineRepository;
private final br.gov.sigefp.budget.repository.BudgetAllocationRepository budgetAllocationRepository;
public BudgetExecutionDTO registerExecution(BudgetExecutionDTO dto) {
BudgetLine budgetLine = budgetLineRepository.findById(dto.getBudgetLineId())
.orElseThrow(() -> new ResourceNotFoundException(
"Linha orçamentária não encontrada: " + dto.getBudgetLineId()));
// Validação 1: não permitir registrar movimentos num fiscalYear fechado
if ("CLOSED".equals(budgetLine.getFiscalYear().getStatus())) {
throw new BusinessException("Não é possível registrar movimentos em um ano fiscal fechado",
"FISCAL_YEAR_CLOSED", org.springframework.http.HttpStatus.CONFLICT);
}
// Validação 2: Coerência Governamental - Verificar saldo disponível para
// COMMITMENT
if ("COMMITMENT".equals(dto.getMovementType())) {
BigDecimal totalAllocated = budgetAllocationRepository
.calculateTotalAllocatedByBudgetLineId(budgetLine.getId());
BigDecimal totalCommitted = budgetExecutionRepository
.calculateTotalCommittedByBudgetLineId(budgetLine.getId());
BigDecimal available = totalAllocated.subtract(totalCommitted);
if (dto.getAmount().compareTo(available) > 0) {
throw new InsufficientBudgetException(String.format(
"Saldo insuficiente na Linha Orçamental %s. Disponível: %s, Solicitado: %s",
budgetLine.getCode(), available, dto.getAmount()));
}
}
// Validação 3: Sequência obrigatória para LIQUIDATION (exige COMMITMENT)
if ("LIQUIDATION".equals(dto.getMovementType())) {
if (dto.getReferenceId() == null) {
throw new BusinessException("ReferenceId é obrigatório para LIQUIDATION",
"MISSING_REFERENCE_ID", org.springframework.http.HttpStatus.BAD_REQUEST);
}
BigDecimal totalCommitted = budgetExecutionRepository
.calculateTotalCommittedByReferenceId(dto.getReferenceId());
if (totalCommitted.compareTo(BigDecimal.ZERO) == 0) {
throw new BusinessException("Não existe COMMITMENT correspondente para liquidar",
"NO_COMMITMENT", org.springframework.http.HttpStatus.CONFLICT);
}
BigDecimal totalLiquidated = budgetExecutionRepository
.calculateTotalLiquidatedByReferenceId(dto.getReferenceId());
BigDecimal availableToLiquidate = totalCommitted.subtract(totalLiquidated);
if (dto.getAmount().compareTo(availableToLiquidate) > 0) {
throw new BusinessException(
String.format("Liquidação não pode exceder empenho. Disponível: %s, Solicitado: %s",
availableToLiquidate, dto.getAmount()),
"INSUFFICIENT_COMMITMENT", org.springframework.http.HttpStatus.CONFLICT);
}
}
// Validação 4: Sequência obrigatória para PAYMENT (exige LIQUIDATION)
if ("PAYMENT".equals(dto.getMovementType())) {
if (dto.getReferenceId() == null) {
throw new BusinessException("ReferenceId é obrigatório para PAYMENT",
"MISSING_REFERENCE_ID", org.springframework.http.HttpStatus.BAD_REQUEST);
}
BigDecimal totalLiquidated = budgetExecutionRepository
.calculateTotalLiquidatedByReferenceId(dto.getReferenceId());
if (totalLiquidated.compareTo(BigDecimal.ZERO) == 0) {
throw new BusinessException("Não existe LIQUIDATION correspondente para pagar",
"NO_LIQUIDATION", org.springframework.http.HttpStatus.CONFLICT);
}
BigDecimal totalPaid = budgetExecutionRepository
.calculateTotalPaidByReferenceId(dto.getReferenceId());
BigDecimal availableToPay = totalLiquidated.subtract(totalPaid);
if (dto.getAmount().compareTo(availableToPay) > 0) {
throw new BusinessException(
String.format("Pagamento não pode exceder liquidação. Disponível: %s, Solicitado: %s",
availableToPay, dto.getAmount()),
"INSUFFICIENT_LIQUIDATION", org.springframework.http.HttpStatus.CONFLICT);
}
}
BudgetExecution execution = BudgetExecution.builder()
.budgetLine(budgetLine)
.periodId(dto.getPeriodId())
.movementType(dto.getMovementType())
.amount(dto.getAmount())
.sourceModule(dto.getSourceModule())
.referenceId(dto.getReferenceId())
.build();
BudgetExecution saved = budgetExecutionRepository.save(execution);
return toDTO(saved);
}
@Transactional(readOnly = true)
public BigDecimal calculateTotalCommittedByBudgetLineId(UUID budgetLineId) {
return budgetExecutionRepository.calculateTotalCommittedByBudgetLineId(budgetLineId);
}
@Transactional(readOnly = true)
public BigDecimal calculateAvailableBalance(UUID budgetLineId) {
// Este método será usado pelo BudgetLineService, mas pode ser chamado
// diretamente
// O cálculo é: totalAllocated - totalCommitted
// Mas precisamos do BudgetAllocationRepository para isso
// Por enquanto, retornamos apenas o comprometido
return budgetExecutionRepository.calculateTotalCommittedByBudgetLineId(budgetLineId);
}
@Transactional(readOnly = true)
public List<BudgetExecutionDTO> findByBudgetLineId(UUID budgetLineId) {
return budgetExecutionRepository.findByBudgetLineId(budgetLineId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<BudgetExecutionDTO> findByBudgetLineIdAndMovementType(UUID budgetLineId, String movementType) {
return budgetExecutionRepository.findByBudgetLineIdAndMovementType(budgetLineId, movementType).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<BudgetExecutionDTO> findByPeriodId(Long periodId) {
return budgetExecutionRepository.findByPeriodId(periodId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public Page<BudgetExecutionDTO> findByFilters(UUID budgetLineId, Long periodId, String movementType,
Pageable pageable) {
org.springframework.data.jpa.domain.Specification<BudgetExecution> spec = (root, query, cb) -> {
var predicates = new java.util.ArrayList<jakarta.persistence.criteria.Predicate>();
if (budgetLineId != null) {
predicates.add(cb.equal(root.get("budgetLine").get("id"), budgetLineId));
}
if (periodId != null) {
predicates.add(cb.equal(root.get("periodId"), periodId));
}
if (movementType != null) {
predicates.add(cb.equal(root.get("movementType"), movementType));
}
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
};
return budgetExecutionRepository.findAll(spec, pageable)
.map(this::toDTO);
}
@Transactional(readOnly = true)
public List<BudgetExecutionDTO> findByFiltersList(UUID budgetLineId, Long periodId, String movementType) {
return budgetExecutionRepository.findByFiltersList(budgetLineId, periodId, movementType).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public Page<BudgetExecutionDTO> findAll(Pageable pageable) {
return budgetExecutionRepository.findAll(pageable).map(this::toDTO);
}
private BudgetExecutionDTO toDTO(BudgetExecution execution) {
String typePt = translateMovementType(execution.getMovementType());
String sourcePt = translateSourceModule(execution.getSourceModule());
StringBuilder desc = new StringBuilder();
desc.append(typePt);
if (sourcePt != null && !sourcePt.isEmpty()) {
desc.append(" - ").append(sourcePt);
}
if (execution.getPeriodId() != null) {
String p = execution.getPeriodId().toString();
if (p.length() == 6) {
desc.append(" (Ref: ").append(p.substring(4, 6)).append("/").append(p.substring(0, 4)).append(")");
} else {
desc.append(" (Ref: ").append(p).append(")");
}
}
return BudgetExecutionDTO.builder()
.id(execution.getId())
.budgetLineId(execution.getBudgetLine().getId())
.budgetLineCode(
execution.getBudgetLine().getCode() + " - " + execution.getBudgetLine().getDescription())
.periodId(execution.getPeriodId())
.movementType(execution.getMovementType())
.amount(execution.getAmount())
.createdAt(execution.getCreatedAt())
.sourceModule(execution.getSourceModule())
.referenceId(execution.getReferenceId())
.description(desc.toString())
.build();
}
private String translateMovementType(String type) {
if (type == null)
return "";
return switch (type) {
case "COMMITMENT" -> "Empenho";
case "LIQUIDATION" -> "Liquidação";
case "PAYMENT" -> "Pagamento";
default -> type;
};
}
private String translateSourceModule(String module) {
if (module == null)
return "";
return switch (module.toLowerCase()) {
case "rh", "payroll" -> "Folha de Pagamento";
case "treasury" -> "Tesouraria";
case "budget" -> "Orçamento";
default -> module;
};
}
private String formatCurrency(BigDecimal amount) {
return amount != null ? amount.toString() : "0.00";
}
}
@@ -0,0 +1,195 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.BudgetLineDTO;
import br.gov.sigefp.budget.domain.BudgetLine;
import br.gov.sigefp.budget.domain.FiscalYear;
import br.gov.sigefp.budget.repository.BudgetAllocationRepository;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.budget.repository.BudgetLineRepository;
import br.gov.sigefp.budget.repository.FiscalYearRepository;
import br.gov.sigefp.org.repository.MinistryRepository;
import br.gov.sigefp.org.repository.OrgUnitRepository;
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.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de linhas orçamentárias.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class BudgetLineService {
private final BudgetLineRepository budgetLineRepository;
private final FiscalYearRepository fiscalYearRepository;
private final MinistryRepository ministryRepository;
private final OrgUnitRepository orgUnitRepository;
public BudgetLineDTO create(BudgetLineDTO dto) {
// Validação: não permitir criar duas linhas com o mesmo code para o mesmo
// fiscalYear
if (budgetLineRepository.existsByFiscalYearIdAndCode(dto.getFiscalYearId(), dto.getCode())) {
throw new IllegalArgumentException(
String.format("Linha orçamentária com código %s já existe para o ano fiscal informado",
dto.getCode()));
}
FiscalYear fiscalYear = fiscalYearRepository.findById(dto.getFiscalYearId())
.orElseThrow(() -> new IllegalArgumentException("Ano fiscal não encontrado: " + dto.getFiscalYearId()));
// Validações cruzadas
if (dto.getMinistryId() != null && !ministryRepository.existsById(dto.getMinistryId())) {
throw new IllegalArgumentException("Ministério não encontrado: " + dto.getMinistryId());
}
if (dto.getOrgUnitId() != null && !orgUnitRepository.existsById(dto.getOrgUnitId())) {
throw new IllegalArgumentException("Unidade organizacional não encontrada: " + dto.getOrgUnitId());
}
// Validação: orgUnit deve pertencer ao mesmo ministry
if (dto.getMinistryId() != null && dto.getOrgUnitId() != null) {
orgUnitRepository.findById(dto.getOrgUnitId())
.ifPresent(orgUnit -> {
if (!dto.getMinistryId().equals(orgUnit.getMinistry())) {
throw new IllegalArgumentException(
"Unidade organizacional deve pertencer ao mesmo ministério");
}
});
}
BudgetLine budgetLine = BudgetLine.builder()
.fiscalYear(fiscalYear)
.code(dto.getCode())
.description(dto.getDescription())
.ministry(dto.getMinistryId())
.orgUnit(dto.getOrgUnitId())
.economicClass(dto.getEconomicClass())
.build();
BudgetLine saved = budgetLineRepository.save(budgetLine);
return toDTO(saved);
}
public BudgetLineDTO update(UUID id, BudgetLineDTO dto) {
BudgetLine budgetLine = budgetLineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Linha orçamentária não encontrada: " + id));
if (dto.getCode() != null && !dto.getCode().equals(budgetLine.getCode())) {
if (budgetLineRepository.existsByFiscalYearIdAndCode(budgetLine.getFiscalYear().getId(), dto.getCode())) {
throw new IllegalArgumentException(
String.format("Linha orçamentária com código %s já existe para o ano fiscal", dto.getCode()));
}
budgetLine.setCode(dto.getCode());
}
if (dto.getDescription() != null) {
budgetLine.setDescription(dto.getDescription());
}
if (dto.getMinistryId() != null) {
if (!ministryRepository.existsById(dto.getMinistryId())) {
throw new IllegalArgumentException("Ministério não encontrado: " + dto.getMinistryId());
}
budgetLine.setMinistry(dto.getMinistryId());
}
if (dto.getOrgUnitId() != null) {
if (!orgUnitRepository.existsById(dto.getOrgUnitId())) {
throw new IllegalArgumentException("Unidade organizacional não encontrada: " + dto.getOrgUnitId());
}
// Validação: orgUnit deve pertencer ao mesmo ministry
orgUnitRepository.findById(dto.getOrgUnitId())
.ifPresent(orgUnit -> {
UUID currentMinistryId = budgetLine.getMinistry();
if (currentMinistryId != null && !currentMinistryId.equals(orgUnit.getMinistry())) {
throw new IllegalArgumentException(
"Unidade organizacional deve pertencer ao mesmo ministério");
}
});
budgetLine.setOrgUnit(dto.getOrgUnitId());
}
if (dto.getEconomicClass() != null) {
budgetLine.setEconomicClass(dto.getEconomicClass());
}
BudgetLine saved = budgetLineRepository.save(budgetLine);
return toDTO(saved);
}
@Transactional(readOnly = true)
public BudgetLineDTO findById(UUID id) {
BudgetLine budgetLine = budgetLineRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Linha orçamentária não encontrada: " + id));
return toDTO(budgetLine);
}
@Transactional(readOnly = true)
public Page<BudgetLineDTO> findAll(Pageable pageable) {
return budgetLineRepository.findAll(pageable).map(this::toDTO);
}
@Transactional(readOnly = true)
public List<BudgetLineDTO> findByFiscalYearId(UUID fiscalYearId) {
return budgetLineRepository.findByFiscalYearId(fiscalYearId).stream()
.map(this::toDTO)
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public Page<BudgetLineDTO> findWithFilters(UUID fiscalYearId, UUID ministryId, UUID orgUnitId, String search,
Pageable pageable) {
return budgetLineRepository.findAll((root, query, cb) -> {
var predicates = new java.util.ArrayList<jakarta.persistence.criteria.Predicate>();
if (fiscalYearId != null) {
predicates.add(cb.equal(root.get("fiscalYear").get("id"), fiscalYearId));
}
if (ministryId != null) {
predicates.add(cb.equal(root.get("ministry"), ministryId));
}
if (orgUnitId != null) {
predicates.add(cb.equal(root.get("orgUnit"), orgUnitId));
}
if (search != null && !search.trim().isEmpty()) {
String likePattern = "%" + search.trim().toLowerCase() + "%";
predicates.add(cb.or(
cb.like(cb.lower(root.get("code")), likePattern),
cb.like(cb.lower(root.get("description")), likePattern),
cb.like(cb.lower(root.get("economicClass")), likePattern)));
}
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
}, pageable).map(this::toDTO);
}
private BudgetLineDTO toDTO(BudgetLine budgetLine) {
// Valores calculados via @Formula no carregamento da entidade (Performance N+2
// fix)
BigDecimal totalAllocated = budgetLine.getTotalAllocated() != null ? budgetLine.getTotalAllocated()
: BigDecimal.ZERO;
BigDecimal totalCommitted = budgetLine.getTotalCommitted() != null ? budgetLine.getTotalCommitted()
: BigDecimal.ZERO;
BigDecimal availableBalance = totalAllocated.subtract(totalCommitted);
return BudgetLineDTO.builder()
.id(budgetLine.getId())
.fiscalYearId(budgetLine.getFiscalYear().getId())
.code(budgetLine.getCode())
.description(budgetLine.getDescription())
.ministryId(budgetLine.getMinistry())
.orgUnitId(budgetLine.getOrgUnit())
.economicClass(budgetLine.getEconomicClass())
.totalAllocated(totalAllocated)
.totalCommitted(totalCommitted)
.availableBalance(availableBalance)
.build();
}
}
@@ -0,0 +1,48 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.EconomicClassificationDTO;
import br.gov.sigefp.budget.domain.EconomicClassification;
import br.gov.sigefp.budget.repository.EconomicClassificationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class EconomicClassificationService {
private final EconomicClassificationRepository repository;
@Transactional(readOnly = true)
public List<EconomicClassificationDTO> findAll(String type) {
List<EconomicClassification> list;
if (type != null && !type.isEmpty()) {
// Using simple findAll and filtering in memory for small list (20-100 items)
// Or could use Specification if needed. Current volume is small.
list = repository.findAll().stream()
.filter(e -> e.getType().equalsIgnoreCase(type))
.collect(Collectors.toList());
} else {
list = repository.findAll();
}
return list.stream()
.sorted(Comparator.comparing(EconomicClassification::getCode))
.map(this::toDTO)
.collect(Collectors.toList());
}
private EconomicClassificationDTO toDTO(EconomicClassification entity) {
return EconomicClassificationDTO.builder()
.id(entity.getId())
.code(entity.getCode())
.description(entity.getDescription())
.type(entity.getType())
.uemoaCode(entity.getUemoaCode())
.build();
}
}
@@ -0,0 +1,100 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.FiscalYearDTO;
import br.gov.sigefp.budget.domain.BudgetExecution;
import br.gov.sigefp.budget.domain.FiscalYear;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.budget.repository.FiscalYearRepository;
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;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Serviço de aplicação para gestão de anos fiscais.
*/
@Service
@RequiredArgsConstructor
@Transactional
public class FiscalYearService {
private final FiscalYearRepository fiscalYearRepository;
private final BudgetExecutionRepository budgetExecutionRepository;
public FiscalYearDTO create(FiscalYearDTO dto) {
if (fiscalYearRepository.existsByYear(dto.getYear())) {
throw new IllegalArgumentException("Ano fiscal já existe: " + dto.getYear());
}
FiscalYear fiscalYear = FiscalYear.builder()
.year(dto.getYear())
.startDate(dto.getStartDate())
.endDate(dto.getEndDate())
.status(dto.getStatus() != null ? dto.getStatus() : "DRAFT")
.build();
FiscalYear saved = fiscalYearRepository.save(fiscalYear);
return toDTO(saved);
}
public FiscalYearDTO open(UUID id) {
FiscalYear fiscalYear = fiscalYearRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ano fiscal não encontrado: " + id));
fiscalYear.setStatus("OPEN");
FiscalYear saved = fiscalYearRepository.save(fiscalYear);
return toDTO(saved);
}
public FiscalYearDTO close(UUID id) {
FiscalYear fiscalYear = fiscalYearRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ano fiscal não encontrado: " + id));
// Validação: não permitir fechar se existirem movimentos em aberto
// Verificar se há execuções pendentes (COMMITMENT) de forma performática
// (COUNT)
long pendingCommitments = budgetExecutionRepository.countByFiscalYearIdAndMovementType(id, "COMMITMENT");
if (pendingCommitments > 0) {
throw new IllegalArgumentException("Não é possível fechar o ano fiscal: existem movimentos em aberto");
}
fiscalYear.setStatus("CLOSED");
FiscalYear saved = fiscalYearRepository.save(fiscalYear);
return toDTO(saved);
}
@Transactional(readOnly = true)
public FiscalYearDTO findById(UUID id) {
FiscalYear fiscalYear = fiscalYearRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Ano fiscal não encontrado: " + id));
return toDTO(fiscalYear);
}
@Transactional(readOnly = true)
public Page<FiscalYearDTO> findAll(Pageable pageable) {
return fiscalYearRepository.findAll(pageable).map(this::toDTO);
}
@Transactional(readOnly = true)
public FiscalYearDTO getCurrentFiscalYear() {
FiscalYear fiscalYear = fiscalYearRepository.findCurrentFiscalYear()
.orElseThrow(() -> new IllegalArgumentException("Não existe ano fiscal aberto"));
return toDTO(fiscalYear);
}
private FiscalYearDTO toDTO(FiscalYear fiscalYear) {
return FiscalYearDTO.builder()
.id(fiscalYear.getId())
.year(fiscalYear.getYear())
.startDate(fiscalYear.getStartDate())
.endDate(fiscalYear.getEndDate())
.status(fiscalYear.getStatus())
.build();
}
}
@@ -0,0 +1,337 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.BudgetExecutionDTO;
import br.gov.sigefp.budget.domain.BudgetExecution;
import br.gov.sigefp.budget.domain.BudgetLine;
import br.gov.sigefp.budget.domain.FiscalYear;
import br.gov.sigefp.budget.repository.BudgetAllocationRepository;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.budget.repository.BudgetLineRepository;
import br.gov.sigefp.common.exception.BusinessException;
import br.gov.sigefp.common.exception.InsufficientBudgetException;
import br.gov.sigefp.common.exception.ResourceNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BudgetExecutionServiceTest {
@Mock
private BudgetExecutionRepository budgetExecutionRepository;
@Mock
private BudgetLineRepository budgetLineRepository;
@Mock
private BudgetAllocationRepository budgetAllocationRepository;
@InjectMocks
private BudgetExecutionService budgetExecutionService;
private UUID budgetLineId;
private BudgetLine budgetLine;
private FiscalYear fiscalYear;
@BeforeEach
void setUp() {
budgetLineId = UUID.randomUUID();
fiscalYear = FiscalYear.builder()
.year(2024)
.status("OPEN")
.build();
budgetLine = BudgetLine.builder()
.id(budgetLineId)
.code("01.01.01")
.fiscalYear(fiscalYear)
.build();
}
@Test
@DisplayName("Deve registrar execução com sucesso quando há saldo")
void registerExecution_Success() {
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("COMMITMENT")
.amount(new BigDecimal("1000"))
.periodId(202401L)
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetAllocationRepository.calculateTotalAllocatedByBudgetLineId(budgetLineId))
.thenReturn(new BigDecimal("5000"));
when(budgetExecutionRepository.calculateTotalCommittedByBudgetLineId(budgetLineId))
.thenReturn(new BigDecimal("2000"));
when(budgetExecutionRepository.save(any(BudgetExecution.class))).thenAnswer(i -> {
BudgetExecution e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
BudgetExecutionDTO result = budgetExecutionService.registerExecution(dto);
assertNotNull(result);
assertEquals(new BigDecimal("1000"), result.getAmount());
verify(budgetExecutionRepository).save(any(BudgetExecution.class));
}
@Test
@DisplayName("Deve falhar ao registrar COMMITMENT se não houver saldo disponível")
void registerExecution_InsufficientFunds() {
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("COMMITMENT")
.amount(new BigDecimal("4000"))
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetAllocationRepository.calculateTotalAllocatedByBudgetLineId(budgetLineId))
.thenReturn(new BigDecimal("5000"));
when(budgetExecutionRepository.calculateTotalCommittedByBudgetLineId(budgetLineId))
.thenReturn(new BigDecimal("2000")); // Disponível = 3000
InsufficientBudgetException exception = assertThrows(InsufficientBudgetException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("Saldo insuficiente"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao registrar execução em exercício fiscal fechado")
void registerExecution_ClosedYear() {
fiscalYear.setStatus("CLOSED");
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertEquals("Não é possível registrar movimentos em um ano fiscal fechado", exception.getMessage());
}
@Test
@DisplayName("Deve falhar ao criar LIQUIDATION sem COMMITMENT correspondente")
void registerExecution_LiquidationWithoutCommitment() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("LIQUIDATION")
.amount(new BigDecimal("1000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("rh")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalCommittedByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Sem COMMITMENT
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("Não existe COMMITMENT correspondente"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao criar LIQUIDATION sem referenceId")
void registerExecution_LiquidationWithoutReferenceId() {
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("LIQUIDATION")
.amount(new BigDecimal("1000"))
.referenceId(null) // Sem referenceId
.periodId(202401L)
.sourceModule("rh")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("ReferenceId é obrigatório"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao criar LIQUIDATION excedendo COMMITMENT")
void registerExecution_LiquidationExceedingCommitment() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("LIQUIDATION")
.amount(new BigDecimal("2000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("rh")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalCommittedByReferenceId(referenceId))
.thenReturn(new BigDecimal("1000")); // COMMITMENT de 1000
when(budgetExecutionRepository.calculateTotalLiquidatedByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Nenhuma liquidação ainda
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("Liquidação não pode exceder empenho"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao criar PAYMENT sem LIQUIDATION correspondente")
void registerExecution_PaymentWithoutLiquidation() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("PAYMENT")
.amount(new BigDecimal("1000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("treasury")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalLiquidatedByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Sem LIQUIDATION
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("Não existe LIQUIDATION correspondente"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao criar PAYMENT sem referenceId")
void registerExecution_PaymentWithoutReferenceId() {
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("PAYMENT")
.amount(new BigDecimal("1000"))
.referenceId(null) // Sem referenceId
.periodId(202401L)
.sourceModule("treasury")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("ReferenceId é obrigatório"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve falhar ao criar PAYMENT excedendo LIQUIDATION")
void registerExecution_PaymentExceedingLiquidation() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("PAYMENT")
.amount(new BigDecimal("2000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("treasury")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalLiquidatedByReferenceId(referenceId))
.thenReturn(new BigDecimal("1000")); // LIQUIDATION de 1000
when(budgetExecutionRepository.calculateTotalPaidByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Nenhum pagamento ainda
BusinessException exception = assertThrows(BusinessException.class,
() -> budgetExecutionService.registerExecution(dto));
assertTrue(exception.getMessage().contains("Pagamento não pode exceder liquidação"));
verify(budgetExecutionRepository, never()).save(any());
}
@Test
@DisplayName("Deve registrar LIQUIDATION com sucesso quando há COMMITMENT correspondente")
void registerExecution_LiquidationSuccess() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("LIQUIDATION")
.amount(new BigDecimal("1000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("rh")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalCommittedByReferenceId(referenceId))
.thenReturn(new BigDecimal("2000")); // COMMITMENT de 2000
when(budgetExecutionRepository.calculateTotalLiquidatedByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Nenhuma liquidação ainda
when(budgetExecutionRepository.save(any(BudgetExecution.class))).thenAnswer(i -> {
BudgetExecution e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
BudgetExecutionDTO result = budgetExecutionService.registerExecution(dto);
assertNotNull(result);
assertEquals(new BigDecimal("1000"), result.getAmount());
assertEquals("LIQUIDATION", result.getMovementType());
verify(budgetExecutionRepository).save(any(BudgetExecution.class));
}
@Test
@DisplayName("Deve registrar PAYMENT com sucesso quando há LIQUIDATION correspondente")
void registerExecution_PaymentSuccess() {
UUID referenceId = UUID.randomUUID();
BudgetExecutionDTO dto = BudgetExecutionDTO.builder()
.budgetLineId(budgetLineId)
.movementType("PAYMENT")
.amount(new BigDecimal("1000"))
.referenceId(referenceId)
.periodId(202401L)
.sourceModule("treasury")
.build();
when(budgetLineRepository.findById(budgetLineId)).thenReturn(Optional.of(budgetLine));
when(budgetExecutionRepository.calculateTotalLiquidatedByReferenceId(referenceId))
.thenReturn(new BigDecimal("2000")); // LIQUIDATION de 2000
when(budgetExecutionRepository.calculateTotalPaidByReferenceId(referenceId))
.thenReturn(BigDecimal.ZERO); // Nenhum pagamento ainda
when(budgetExecutionRepository.save(any(BudgetExecution.class))).thenAnswer(i -> {
BudgetExecution e = i.getArgument(0);
e.setId(UUID.randomUUID());
return e;
});
BudgetExecutionDTO result = budgetExecutionService.registerExecution(dto);
assertNotNull(result);
assertEquals(new BigDecimal("1000"), result.getAmount());
assertEquals("PAYMENT", result.getMovementType());
verify(budgetExecutionRepository).save(any(BudgetExecution.class));
}
}
@@ -0,0 +1,71 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.BudgetLineDTO;
import br.gov.sigefp.budget.domain.BudgetLine;
import br.gov.sigefp.budget.domain.FiscalYear;
import br.gov.sigefp.budget.repository.BudgetLineRepository;
import br.gov.sigefp.budget.repository.FiscalYearRepository;
import br.gov.sigefp.org.repository.MinistryRepository;
import br.gov.sigefp.org.repository.OrgUnitRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BudgetLineServiceTest {
@Mock
private BudgetLineRepository budgetLineRepository;
@Mock
private FiscalYearRepository fiscalYearRepository;
@Mock
private MinistryRepository ministryRepository;
@Mock
private OrgUnitRepository orgUnitRepository;
@InjectMocks
private BudgetLineService budgetLineService;
private BudgetLine budgetLine;
@BeforeEach
void setUp() {
FiscalYear fiscalYear = FiscalYear.builder().year(2025).status("OPEN").build();
fiscalYear.setId(UUID.randomUUID());
budgetLine = BudgetLine.builder()
.id(UUID.randomUUID())
.code("01.01")
.description("Teste")
.fiscalYear(fiscalYear)
.totalAllocated(new BigDecimal("1000"))
.totalCommitted(new BigDecimal("200"))
.build();
}
@Test
@DisplayName("Should use pre-calculated Formula fields for DTO conversion")
void toDTO_ShouldUseFormulaFields() {
when(budgetLineRepository.findById(budgetLine.getId())).thenReturn(Optional.of(budgetLine));
BudgetLineDTO dto = budgetLineService.findById(budgetLine.getId());
assertNotNull(dto);
assertEquals(new BigDecimal("1000"), dto.getTotalAllocated());
assertEquals(new BigDecimal("200"), dto.getTotalCommitted());
assertEquals(new BigDecimal("800"), dto.getAvailableBalance());
// Verify no extra repository calls are made (implicitly verified by lack of
// mocks for them)
}
}
@@ -0,0 +1,64 @@
package br.gov.sigefp.budget.service;
import br.gov.sigefp.budget.api.dto.FiscalYearDTO;
import br.gov.sigefp.budget.domain.FiscalYear;
import br.gov.sigefp.budget.repository.BudgetExecutionRepository;
import br.gov.sigefp.budget.repository.FiscalYearRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class FiscalYearServiceTest {
@Mock
private FiscalYearRepository fiscalYearRepository;
@Mock
private BudgetExecutionRepository budgetExecutionRepository;
@InjectMocks
private FiscalYearService fiscalYearService;
@Test
@DisplayName("Should use efficient COUNT query to check pending commitments")
void close_ShouldUseCountQuery() {
UUID id = UUID.randomUUID();
FiscalYear fiscalYear = FiscalYear.builder().id(id).status("OPEN").build();
when(fiscalYearRepository.findById(id)).thenReturn(Optional.of(fiscalYear));
when(budgetExecutionRepository.countByFiscalYearIdAndMovementType(id, "COMMITMENT")).thenReturn(0L); // No
// pending
when(fiscalYearRepository.save(any(FiscalYear.class))).thenReturn(fiscalYear);
fiscalYearService.close(id);
verify(budgetExecutionRepository).countByFiscalYearIdAndMovementType(id, "COMMITMENT");
verify(budgetExecutionRepository, never()).findExecutionsByFiscalYearId(any()); // Ensure list is NOT loaded
}
@Test
@DisplayName("Should block closing if pending commitments exist")
void close_ShouldBlockIfPending() {
UUID id = UUID.randomUUID();
FiscalYear fiscalYear = FiscalYear.builder().id(id).status("OPEN").build();
when(fiscalYearRepository.findById(id)).thenReturn(Optional.of(fiscalYear));
when(budgetExecutionRepository.countByFiscalYearIdAndMovementType(id, "COMMITMENT")).thenReturn(5L); // 5
// pending
Exception exception = assertThrows(IllegalArgumentException.class, () -> fiscalYearService.close(id));
assertTrue(exception.getMessage().contains("existem movimentos em aberto"));
verify(fiscalYearRepository, never()).save(any());
}
}