diff --git a/api/pom.xml b/api/pom.xml
index ccb77dc..e821b3e 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -152,14 +152,13 @@
test
-
com.networknt
json-schema-validator
${json-schema-validator.version}
- test
diff --git a/api/src/main/java/synapse/api/job/BuscarJobService.java b/api/src/main/java/synapse/api/job/BuscarJobService.java
index 40dcf40..cb892ca 100644
--- a/api/src/main/java/synapse/api/job/BuscarJobService.java
+++ b/api/src/main/java/synapse/api/job/BuscarJobService.java
@@ -7,6 +7,7 @@
import java.util.UUID;
import tools.jackson.core.type.TypeReference;
+import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
@@ -19,7 +20,9 @@ class BuscarJobService {
private final JdbcTemplate jdbc;
- private final JsonMapper json = new JsonMapper();
+ private final JsonMapper json = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .build();
BuscarJobService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
@@ -31,13 +34,14 @@ JobDetalhadoDto buscar(UUID jobId) {
SELECT
j.id,
j.status,
- s.tipo AS origem,
+ CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem,
j.competencias,
j.orcamento,
j.criado_em,
j.iniciado_em,
j.finalizado_em,
j.submissao_id,
+ j.job_origem_id,
regras.regras,
sim.id AS simulacao_id,
@@ -51,7 +55,7 @@ JobDetalhadoDto buscar(UUID jobId) {
rs.decomposicao AS simulacao_decomposicao
FROM jobs j
- JOIN submissoes s ON s.id = j.submissao_id
+ LEFT JOIN submissoes s ON s.id = j.submissao_id
JOIN LATERAL (
SELECT jsonb_agg(
jsonb_build_object(
@@ -87,6 +91,7 @@ LEFT JOIN LATERAL (
Instant finalizadoEm = rs.getTimestamp("finalizado_em") != null
? rs.getTimestamp("finalizado_em").toInstant() : null;
UUID submissaoId = rs.getObject("submissao_id", UUID.class);
+ UUID jobOrigemId = rs.getObject("job_origem_id", UUID.class);
List regras = regras(rs.getString("regras"));
@@ -125,7 +130,7 @@ LEFT JOIN LATERAL (
}
return new JobDetalhadoDto(id, status, origem, competencias, orcamento, criadoEm, iniciadoEm,
- finalizadoEm, submissaoId, regras, simulacao);
+ finalizadoEm, submissaoId, jobOrigemId, regras, simulacao);
}, jobId);
}
catch (EmptyResultDataAccessException ex) {
@@ -138,9 +143,7 @@ private List regras(String regrasJson) {
List regras = new ArrayList<>();
for (JsonNode regra : raiz) {
NucleoRegraDto nucleo = this.json.readValue(regra.path("nucleo").toString(), NucleoRegraDto.class);
- List especificacoes = this.json.readValue(regra.path("especificacoes").toString(),
- new TypeReference>() {
- });
+ List especificacoes = regra.path("especificacoes").valueStream().toList();
RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(nucleo, especificacoes);
regras.add(new RegraCriadaDto(UUID.fromString(regra.path("id").asString()), regra.path("versao").asInt(),
regra.path("origem").asString(), representacao, Instant.parse(regra.path("criada_em").asString())));
diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java
index f076a9c..a451ffb 100644
--- a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java
+++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java
@@ -5,6 +5,12 @@
import java.util.HashSet;
import java.util.List;
+import com.networknt.schema.InputFormat;
+import com.networknt.schema.JsonSchema;
+import com.networknt.schema.JsonSchemaFactory;
+import com.networknt.schema.SchemaLocation;
+import com.networknt.schema.SchemaValidatorsConfig;
+import com.networknt.schema.SpecVersion.VersionFlag;
import org.jspecify.annotations.Nullable;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
@@ -24,6 +30,13 @@ record ConfirmarParametrosRequisicao(RepresentacaoRegraDto representacao, @Nulla
private static final List CAMPOS_NUCLEO = List.of("vigencia", "loja", "marca", "cargo", "percentual");
+ private static final JsonSchema ESPECIFICACOES = JsonSchemaFactory
+ .getInstance(VersionFlag.V202012,
+ builder -> builder.schemaMappers(mappers -> mappers.mapPrefix("https://synapse.local/contracts/domain/",
+ "classpath:static/openapi/domain/")))
+ .getSchema(SchemaLocation.of("classpath:static/openapi/domain/regra-especificacoes.schema.json"),
+ SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build());
+
private static final JsonMapper JSON = JsonMapper.builder()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
@@ -47,7 +60,8 @@ static ConfirmarParametrosRequisicao deJson(String corpo) {
validarNucleo(regra.path("nucleo"));
validarEspecificacoes(regra.path("especificacoes"));
RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(
- JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), List.of());
+ JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class),
+ regra.path("especificacoes").valueStream().toList());
return new ConfirmarParametrosRequisicao(representacao, orcamento(raiz.path("orcamento")), competencias(raiz));
}
@@ -96,12 +110,10 @@ private static void validarNucleo(JsonNode nucleo) {
}
private static void validarEspecificacoes(JsonNode especificacoes) {
- if (especificacoes.isMissingNode() || especificacoes.isNull()) {
- return;
- }
- if (!especificacoes.isArray() || !especificacoes.isEmpty()) {
+ if (!especificacoes.isArray()
+ || !ESPECIFICACOES.validate(especificacoes.toString(), InputFormat.JSON).isEmpty()) {
throw ConfirmarParametrosException
- .requisicao("Na Sprint 1, regra.especificacoes deve ser uma lista vazia.");
+ .requisicao("O campo regra.especificacoes deve ser uma lista de elementos válidos da regra.");
}
}
diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java
index 85322d1..982bf1b 100644
--- a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java
+++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java
@@ -7,12 +7,15 @@
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.jspecify.annotations.Nullable;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import org.springframework.dao.EmptyResultDataAccessException;
@@ -40,7 +43,9 @@ class ConfirmarParametrosService {
private final Outbox outbox;
- private final JsonMapper json = new JsonMapper();
+ private final JsonMapper json = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .build();
ConfirmarParametrosService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) {
this.jdbc = jdbc;
@@ -67,25 +72,26 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) {
this.outbox.registrar(jobId, EventoOutbox.PARAMETROS_CONFIRMADOS,
new ParametrosConfirmadosDto(jobId, versao.id()));
- registrarTrilha(jobId, versao.id(), editado, representacao.nucleo(), anterior, timestamp);
+ registrarTrilha(jobId, versao.id(), editado, representacao, anterior, timestamp);
- RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), "confirmacao_usuario", representacao,
+ RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), versao.origem(), representacao,
versao.criadaEm());
return new JobCriadoDto(jobId, JobStatus.GERANDO_REGRA.paraColuna(), dados.origem(), competencias, orcamento,
- dados.criadoEm(), dados.submissaoId(), regra);
+ dados.criadoEm(), dados.submissaoId(), dados.jobOrigemId(), regra);
}
private DadosDoJob carregarJob(UUID jobId) {
try {
return Objects.requireNonNull(this.jdbc.queryForObject("""
- SELECT s.tipo AS origem, j.orcamento, j.criado_em, j.submissao_id
- FROM jobs j JOIN submissoes s ON s.id = j.submissao_id
+ SELECT CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem,
+ j.orcamento, j.criado_em, j.submissao_id, j.job_origem_id
+ FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id
WHERE j.id = ?
""",
(rs, linha) -> new DadosDoJob(Objects.requireNonNull(rs.getString("origem")),
Objects.requireNonNull(rs.getBigDecimal("orcamento")),
Objects.requireNonNull(rs.getTimestamp("criado_em")).toInstant(),
- Objects.requireNonNull(rs.getObject("submissao_id", UUID.class))),
+ rs.getObject("submissao_id", UUID.class), rs.getObject("job_origem_id", UUID.class)),
jobId));
}
catch (EmptyResultDataAccessException ex) {
@@ -94,12 +100,16 @@ private DadosDoJob carregarJob(UUID jobId) {
}
private @Nullable VersaoAnterior ultimaVersao(UUID jobId) {
- List versoes = this.jdbc.query("""
- SELECT id, versao, hash, nucleo FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1
- """,
+ List versoes = this.jdbc.query(
+ """
+ SELECT id, versao, hash, nucleo, especificacoes FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1
+ """,
(rs, linha) -> new VersaoAnterior(Objects.requireNonNull(rs.getObject("id", UUID.class)),
rs.getInt("versao"), Objects.requireNonNull(rs.getString("hash")),
- this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class)),
+ this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class),
+ this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes")))
+ .valueStream()
+ .toList()),
jobId);
return versoes.isEmpty() ? null : versoes.getFirst();
}
@@ -107,10 +117,11 @@ private DadosDoJob carregarJob(UUID jobId) {
private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representacao, String hash,
@Nullable VersaoAnterior anterior, Timestamp timestamp, Instant agora) {
List existentes = this.jdbc.query("""
- SELECT id, versao, criada_em FROM regras WHERE job_id = ? AND hash = ?
+ SELECT id, versao, origem, criada_em FROM regras WHERE job_id = ? AND hash = ?
""",
(rs, linha) -> new VersaoRegra(Objects.requireNonNull(rs.getObject("id", UUID.class)),
- rs.getInt("versao"), Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()),
+ rs.getInt("versao"), Objects.requireNonNull(rs.getString("origem")),
+ Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()),
jobId, hash);
if (!existentes.isEmpty()) {
return existentes.getFirst();
@@ -119,10 +130,10 @@ private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representac
UUID origemId = (anterior != null) ? anterior.id() : null;
UUID id = Objects.requireNonNull(this.jdbc.queryForObject("""
INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em)
- VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, '[]'::jsonb, ?, ?) RETURNING id
+ VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, ?) RETURNING id
""", UUID.class, jobId, novaVersao, origemId, this.json.writeValueAsString(representacao.nucleo()),
- hash, timestamp));
- return new VersaoRegra(id, novaVersao, agora);
+ this.json.writeValueAsString(representacao.especificacoes()), hash, timestamp));
+ return new VersaoRegra(id, novaVersao, "confirmacao_usuario", agora);
}
private BigDecimal resolverOrcamento(UUID jobId, ConfirmarParametrosRequisicao requisicao, BigDecimal atual) {
@@ -144,11 +155,15 @@ private List resolverCompetencias(UUID jobId, ConfirmarParametrosRequisi
return this.jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId);
}
- private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, NucleoRegraDto atual,
+ private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, RepresentacaoRegraDto atual,
@Nullable VersaoAnterior anterior, Timestamp timestamp) {
- List corrigidos = (anterior != null) ? camposCorrigidos(atual, anterior.nucleo()) : List.of();
- String resumo = (editado && !corrigidos.isEmpty())
- ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar"
+ List corrigidos = (anterior != null && editado) ? camposCorrigidos(atual.nucleo(), anterior.nucleo())
+ : new ArrayList<>();
+ if (anterior != null && editado) {
+ corrigidos.addAll(especificacoesCorrigidas(atual.especificacoes(), anterior.especificacoes()));
+ }
+ String resumo = editado ? "usuário corrigiu "
+ + (corrigidos.isEmpty() ? "a representação" : String.join(", ", corrigidos)) + " antes de confirmar"
: "usuário confirmou os parâmetros";
Map conclusao = new LinkedHashMap<>();
conclusao.put("resumo", resumo);
@@ -189,13 +204,27 @@ private static boolean percentualDiferente(@Nullable BigDecimal atual, @Nullable
return atual.compareTo(anterior) != 0;
}
- private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, UUID submissaoId) {
+ private static List especificacoesCorrigidas(List atuais, List anteriores) {
+ var refs = new LinkedHashSet();
+ atuais.forEach(elemento -> refs.add(elemento.path("ref").asString()));
+ anteriores.forEach(elemento -> refs.add(elemento.path("ref").asString()));
+ return refs.stream()
+ .filter(ref -> !atuais.stream()
+ .filter(elemento -> ref.equals(elemento.path("ref").asString()))
+ .toList()
+ .equals(anteriores.stream().filter(elemento -> ref.equals(elemento.path("ref").asString())).toList()))
+ .toList();
+ }
+
+ private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, @Nullable UUID submissaoId,
+ @Nullable UUID jobOrigemId) {
}
- private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo) {
+ private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo,
+ List especificacoes) {
}
- private record VersaoRegra(UUID id, int versao, Instant criadaEm) {
+ private record VersaoRegra(UUID id, int versao, String origem, Instant criadaEm) {
}
}
diff --git a/api/src/main/java/synapse/api/job/CriarJobService.java b/api/src/main/java/synapse/api/job/CriarJobService.java
index 45cefd3..9e223d8 100644
--- a/api/src/main/java/synapse/api/job/CriarJobService.java
+++ b/api/src/main/java/synapse/api/job/CriarJobService.java
@@ -76,7 +76,7 @@ INSERT INTO regras (job_id, versao, origem, nucleo, especificacoes, hash, criada
this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA,
new RegraSubmetidaDto(jobId, requisicao.origem(), requisicao.competencias(), submissaoId, regraId));
return new JobCriadoDto(jobId, status, requisicao.origem(), requisicao.competencias(), requisicao.orcamento(),
- agora, submissaoId, regra);
+ agora, submissaoId, null, regra);
}
}
diff --git a/api/src/main/java/synapse/api/job/HashDaRegra.java b/api/src/main/java/synapse/api/job/HashDaRegra.java
index 70534dc..5d3845b 100644
--- a/api/src/main/java/synapse/api/job/HashDaRegra.java
+++ b/api/src/main/java/synapse/api/job/HashDaRegra.java
@@ -8,6 +8,7 @@
import tools.jackson.core.StreamWriteFeature;
import tools.jackson.databind.MapperFeature;
+import tools.jackson.databind.cfg.JsonNodeFeature;
import tools.jackson.databind.json.JsonMapper;
final class HashDaRegra {
@@ -16,6 +17,7 @@ final class HashDaRegra {
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
.disable(MapperFeature.SORT_CREATOR_PROPERTIES_FIRST)
.enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN)
+ .enable(JsonNodeFeature.WRITE_PROPERTIES_SORTED)
.build();
private HashDaRegra() {
diff --git a/api/src/main/java/synapse/api/job/JobCriadoDto.java b/api/src/main/java/synapse/api/job/JobCriadoDto.java
index 1b17e58..39c25fc 100644
--- a/api/src/main/java/synapse/api/job/JobCriadoDto.java
+++ b/api/src/main/java/synapse/api/job/JobCriadoDto.java
@@ -4,10 +4,12 @@
import java.time.Instant;
import java.util.List;
import java.util.UUID;
+import com.fasterxml.jackson.annotation.JsonInclude;
import org.jspecify.annotations.Nullable;
record JobCriadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento,
- Instant criado_em, UUID submissao_id, RegraCriadaDto regra) {
+ Instant criado_em, @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id,
+ @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, RegraCriadaDto regra) {
}
record RegraCriadaDto(UUID id, int versao, String origem, RepresentacaoRegraDto representacao, Instant criada_em) {
@@ -18,6 +20,8 @@ record SimulacaoDto(UUID id, Instant criado_em, @Nullable String status, @Nullab
}
record JobDetalhadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento,
- Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, UUID submissao_id,
- List regras, @Nullable SimulacaoDto simulacao) {
+ Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em,
+ @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id,
+ @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, List regras,
+ @Nullable SimulacaoDto simulacao) {
}
diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java
new file mode 100644
index 0000000..83cfebc
--- /dev/null
+++ b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java
@@ -0,0 +1,34 @@
+package synapse.api.job;
+
+import org.springframework.dao.DataAccessException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@RestControllerAdvice(assignableTypes = ReprocessarJobController.class)
+class ReprocessarJobAdvice {
+
+ @ExceptionHandler(ReprocessarJobException.class)
+ ResponseEntity tratar(ReprocessarJobException ex) {
+ return ResponseEntity.status(ex.status()).body(ex.erro());
+ }
+
+ @ExceptionHandler(JobNaoEncontradoException.class)
+ ResponseEntity jobNaoEncontrado() {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(new ErroDto("job_nao_encontrado", "Job não encontrado."));
+ }
+
+ @ExceptionHandler(HttpMessageNotReadableException.class)
+ ResponseEntity corpoInvalido() {
+ return tratar(ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."));
+ }
+
+ @ExceptionHandler(DataAccessException.class)
+ ResponseEntity falhaDePersistencia() {
+ return ResponseEntity.internalServerError().build();
+ }
+
+}
diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobController.java b/api/src/main/java/synapse/api/job/ReprocessarJobController.java
new file mode 100644
index 0000000..9c59521
--- /dev/null
+++ b/api/src/main/java/synapse/api/job/ReprocessarJobController.java
@@ -0,0 +1,30 @@
+package synapse.api.job;
+
+import java.net.URI;
+import java.util.UUID;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+class ReprocessarJobController {
+
+ private final ReprocessarJobService service;
+
+ ReprocessarJobController(ReprocessarJobService service) {
+ this.service = service;
+ }
+
+ @PostMapping(path = "/jobs/{id}/reprocessar", produces = "application/json")
+ ResponseEntity reprocessar(@PathVariable("id") UUID id,
+ @RequestBody(required = false) @Nullable String corpo) {
+ JobCriadoDto job = this.service.reprocessar(id, ReprocessarJobRequisicao.deJson(corpo));
+ return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job);
+ }
+
+}
diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobException.java b/api/src/main/java/synapse/api/job/ReprocessarJobException.java
new file mode 100644
index 0000000..a6d6d9c
--- /dev/null
+++ b/api/src/main/java/synapse/api/job/ReprocessarJobException.java
@@ -0,0 +1,39 @@
+package synapse.api.job;
+
+import org.springframework.http.HttpStatus;
+
+class ReprocessarJobException extends RuntimeException {
+
+ private final HttpStatus status;
+
+ private final ErroDto erro;
+
+ private ReprocessarJobException(HttpStatus status, String codigo, String mensagem) {
+ super(mensagem);
+ this.status = status;
+ this.erro = new ErroDto(codigo, mensagem);
+ }
+
+ static ReprocessarJobException requisicao(String mensagem) {
+ return new ReprocessarJobException(HttpStatus.BAD_REQUEST, "requisicao_invalida", mensagem);
+ }
+
+ static ReprocessarJobException estadoInvalido() {
+ return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido",
+ "Somente um job arquivado pode ser reprocessado.");
+ }
+
+ static ReprocessarJobException semRegra() {
+ return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido",
+ "O job arquivado não possui uma regra formada para reprocessamento.");
+ }
+
+ HttpStatus status() {
+ return this.status;
+ }
+
+ ErroDto erro() {
+ return this.erro;
+ }
+
+}
diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java
new file mode 100644
index 0000000..da95b91
--- /dev/null
+++ b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java
@@ -0,0 +1,75 @@
+package synapse.api.job;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+import tools.jackson.core.JacksonException;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+record ReprocessarJobRequisicao(@Nullable BigDecimal orcamento, @Nullable List competencias) {
+
+ private static final List MESES = List.of("2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12");
+
+ private static final JsonMapper JSON = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .build();
+
+ static ReprocessarJobRequisicao deJson(@Nullable String corpo) {
+ if (corpo == null) {
+ return new ReprocessarJobRequisicao(null, null);
+ }
+ JsonNode raiz;
+ try {
+ raiz = JSON.readTree(corpo);
+ }
+ catch (JacksonException ex) {
+ throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido.");
+ }
+ if (raiz == null || !raiz.isObject()) {
+ throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido.");
+ }
+ return new ReprocessarJobRequisicao(orcamento(raiz.path("orcamento")), competencias(raiz));
+ }
+
+ private static @Nullable BigDecimal orcamento(JsonNode orcamento) {
+ if (orcamento.isMissingNode() || orcamento.isNull()) {
+ return null;
+ }
+ if (!orcamento.isNumber()) {
+ throw ReprocessarJobException.requisicao("O campo orcamento precisa ser um número.");
+ }
+ return orcamento.decimalValue();
+ }
+
+ private static @Nullable List competencias(JsonNode raiz) {
+ if (!raiz.has("competencias") || raiz.path("competencias").isNull()) {
+ return null;
+ }
+ JsonNode meses = raiz.path("competencias");
+ if (!meses.isArray()) {
+ throw ReprocessarJobException.requisicao("O campo competencias precisa ser uma lista de meses.");
+ }
+ List competencias = new ArrayList<>();
+ for (JsonNode mes : meses) {
+ if (!mes.isString()) {
+ throw ReprocessarJobException
+ .requisicao("Cada competência precisa ser um mês entre 2025-07 e 2025-12.");
+ }
+ competencias.add(mes.asString());
+ }
+ if (competencias.isEmpty() || !MESES.containsAll(competencias)) {
+ throw ReprocessarJobException
+ .requisicao("Informe competências entre 2025-07 e 2025-12, em uma lista não vazia.");
+ }
+ if (new HashSet<>(competencias).size() != competencias.size()) {
+ throw ReprocessarJobException.requisicao("O campo competencias não permite meses repetidos.");
+ }
+ return competencias.stream().sorted().toList();
+ }
+
+}
diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobService.java b/api/src/main/java/synapse/api/job/ReprocessarJobService.java
new file mode 100644
index 0000000..79af417
--- /dev/null
+++ b/api/src/main/java/synapse/api/job/ReprocessarJobService.java
@@ -0,0 +1,102 @@
+package synapse.api.job;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.json.JsonMapper;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.SqlArrayValue;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import synapse.api.core.outbox.EventoOutbox;
+import synapse.api.core.outbox.Outbox;
+
+@Service
+class ReprocessarJobService {
+
+ private final JdbcTemplate jdbc;
+
+ private final MaquinaDeEstadosDoJob maquina;
+
+ private final Outbox outbox;
+
+ private final JsonMapper json = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .build();
+
+ ReprocessarJobService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) {
+ this.jdbc = jdbc;
+ this.maquina = maquina;
+ this.outbox = outbox;
+ }
+
+ @Transactional
+ JobCriadoDto reprocessar(UUID origemId, ReprocessarJobRequisicao requisicao) {
+ JobDeOrigem origem = carregarOrigem(origemId);
+ if (origem.status() != JobStatus.ARQUIVADO) {
+ throw ReprocessarJobException.estadoInvalido();
+ }
+ List regras = this.jdbc.queryForList("""
+ SELECT id FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1
+ """, UUID.class, origemId);
+ if (regras.isEmpty()) {
+ throw ReprocessarJobException.semRegra();
+ }
+ List competencias = requisicao.competencias() != null ? requisicao.competencias()
+ : origem.competencias();
+ BigDecimal orcamento = requisicao.orcamento() != null ? requisicao.orcamento() : origem.orcamento();
+ Instant agora = Instant.now().truncatedTo(ChronoUnit.MICROS);
+ Timestamp timestamp = Timestamp.from(agora);
+ JobStatus status = JobStatus.AGUARDANDO_CONFIRMACAO_PARAMETROS;
+ UUID jobId = Objects.requireNonNull(this.jdbc.queryForObject("""
+ INSERT INTO jobs (status, usuario_id, job_origem_id, competencias, orcamento, criado_em)
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id
+ """, UUID.class, status.paraColuna(), origem.usuarioId(), origemId,
+ new SqlArrayValue("text", competencias.toArray()), orcamento, timestamp));
+ this.maquina.registrarCriacao(jobId, status, "usuario");
+ RegraCriadaDto regra = Objects.requireNonNull(this.jdbc.queryForObject("""
+ INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em)
+ SELECT ?, 1, 'reprocessamento', id, nucleo, especificacoes, hash, ?
+ FROM regras WHERE id = ?
+ RETURNING id, nucleo, especificacoes
+ """, (rs, linha) -> new RegraCriadaDto(Objects.requireNonNull(rs.getObject("id", UUID.class)), 1,
+ "reprocessamento",
+ new RepresentacaoRegraDto(
+ this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class),
+ this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes")))
+ .valueStream()
+ .toList()),
+ agora), jobId, timestamp, regras.getFirst()));
+ this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA,
+ new RegraSubmetidaDto(jobId, "reprocessamento", competencias, null, regra.id()));
+ return new JobCriadoDto(jobId, status.paraColuna(), "reprocessamento", competencias, orcamento, agora, null,
+ origemId, regra);
+ }
+
+ private JobDeOrigem carregarOrigem(UUID jobId) {
+ List jobs = this.jdbc.query("""
+ SELECT status, usuario_id, competencias, orcamento FROM jobs WHERE id = ?
+ """,
+ (rs, linha) -> new JobDeOrigem(JobStatus.deColuna(Objects.requireNonNull(rs.getString("status"))),
+ Objects.requireNonNull(rs.getObject("usuario_id", UUID.class)),
+ List.of((String[]) rs.getArray("competencias").getArray()),
+ Objects.requireNonNull(rs.getBigDecimal("orcamento"))),
+ jobId);
+ if (jobs.isEmpty()) {
+ throw new JobNaoEncontradoException(jobId);
+ }
+ return jobs.getFirst();
+ }
+
+ private record JobDeOrigem(JobStatus status, UUID usuarioId, List competencias, BigDecimal orcamento) {
+ }
+
+}
diff --git a/api/src/main/java/synapse/api/job/TiposDeJob.java b/api/src/main/java/synapse/api/job/TiposDeJob.java
index 8fa0e24..7d58ae5 100644
--- a/api/src/main/java/synapse/api/job/TiposDeJob.java
+++ b/api/src/main/java/synapse/api/job/TiposDeJob.java
@@ -5,6 +5,7 @@
import java.util.Map;
import org.jspecify.annotations.Nullable;
+import tools.jackson.databind.JsonNode;
record VigenciaDto(
@@ -46,18 +47,11 @@ record NucleoRegraDto(
@Nullable BigDecimal percentual) {
}
-record EspecificacaoRegraDto(
-
- String ref,
-
- String construto) {
-}
-
record RepresentacaoRegraDto(
NucleoRegraDto nucleo,
- List especificacoes) {
+ List especificacoes) {
}
record TotaisSimulacaoDto(
@@ -102,4 +96,4 @@ record ResultadoSimulacaoDto(
List assercoes,
@Nullable DecomposicaoResultadoDto decomposicao) {
-}
\ No newline at end of file
+}
diff --git a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java
index b84f100..97d560b 100644
--- a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java
+++ b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java
@@ -7,13 +7,21 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.json.JsonMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -46,7 +54,7 @@ void preparar() {
void confirmaResponde202ComRegraNaVersaoNova() throws Exception {
when(this.service.confirmar(any(), any())).thenReturn(new JobCriadoDto(JOB, "gerando_regra", "formulario",
List.of("2025-11"), new BigDecimal("485000.0"), Instant.parse("2026-09-20T10:00:00Z"),
- UUID.randomUUID(),
+ UUID.randomUUID(), null,
new RegraCriadaDto(UUID.randomUUID(), 2, "confirmacao_usuario",
new RepresentacaoRegraDto(new NucleoRegraDto(new VigenciaDto("2025-11", "2025-11"),
List.of("13"), List.of("10", "20"), List.of("100", "300"), new BigDecimal("0.03")),
@@ -61,6 +69,57 @@ void confirmaResponde202ComRegraNaVersaoNova() throws Exception {
.andExpect(jsonPath("$.regra.representacao.nucleo.percentual").value(0.03));
}
+ @Test
+ void permiteConfirmarRepresentacaoCompletaSemPerderCamposOuPrecisao() throws Exception {
+ String especificacoes = """
+ [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789,
+ "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125},
+ "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}]
+ """;
+ this.mvc
+ .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON)
+ .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes)))
+ .andExpect(status().isAccepted());
+ var captor = ArgumentCaptor.forClass(ConfirmarParametrosRequisicao.class);
+ verify(this.service).confirmar(eq(JOB), captor.capture());
+ var json = JsonMapper.builder().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).build();
+ assertThat(captor.getValue().representacao().especificacoes())
+ .containsExactlyElementsOf(json.readTree(especificacoes).valueStream().toList());
+ assertThat(captor.getValue()
+ .representacao()
+ .especificacoes()
+ .getFirst()
+ .path("extensao")
+ .path("fator")
+ .decimalValue()).isEqualByComparingTo("0.123456789012345678901");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "null", "{}", "[1]", "[null]", "[{}]",
+ "[{\"ref\":\"invalido\",\"construto\":\"generico\",\"descricao\":\"regra\"}]",
+ "[{\"ref\":\"elem.1\",\"construto\":\"desconhecido\"}]",
+ "[{\"ref\":\"elem.1\",\"construto\":\"generico\"}]",
+ "[{\"ref\":\"elem.1\",\"construto\":\"generico\",\"descricao\":\"\"}]",
+ "[{\"ref\":\"elem.1\",\"construto\":\"faixa_valor\",\"limite_inferior\":0,\"limite_superior\":10,\"efeito\":{\"tipo\":\"invalido\",\"valor\":1}}]",
+ "[{\"ref\":\"elem.1\",\"construto\":\"janela_datas\",\"data_inicial\":\"2025-99-01\",\"data_final\":\"2025-11-30\",\"efeito\":{\"tipo\":\"bonus_fixo\",\"valor\":1}}]" })
+ void especificacoesInvalidasSaoRecusadasSemChamarService(String especificacoes) throws Exception {
+ this.mvc
+ .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON)
+ .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.codigo").value("requisicao_invalida"));
+ verifyNoInteractions(this.service);
+ }
+
+ @Test
+ void especificacoesAusentesSaoRecusadas() throws Exception {
+ this.mvc
+ .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON)
+ .content(CONFIRMAR.replace(",\"especificacoes\":[]", "")))
+ .andExpect(status().isBadRequest());
+ verifyNoInteractions(this.service);
+ }
+
@Test
void corpoInvalidoResponde400() throws Exception {
this.mvc.perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON).content("{"))
diff --git a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java
index 1e15e1b..28c38fd 100644
--- a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java
+++ b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java
@@ -60,7 +60,7 @@ void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exce
when(this.service.criar(any(), any())).thenAnswer(invocacao -> {
CriarJobRequisicao requisicao = invocacao.getArgument(0);
return new JobCriadoDto(jobId, "gerando_regra", "formulario", requisicao.competencias(),
- requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(),
+ requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), null,
new RegraCriadaDto(UUID.randomUUID(), 1, "confirmacao_usuario", requisicao.representacao(),
Instant.parse("2026-09-16T15:00:00Z")));
});
diff --git a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java
index 20e0320..ec79e54 100644
--- a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java
+++ b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java
@@ -65,7 +65,7 @@ private static JobDetalhadoDto jobComStatus(String status) {
new RepresentacaoRegraDto(nucleo, List.of()), Instant.parse("2026-09-16T15:00:00Z"));
return new JobDetalhadoDto(JOB_ID, status, "formulario", List.of("2025-11"), new BigDecimal("485000"),
Instant.parse("2026-09-16T15:00:00Z"), null, Instant.parse("2026-09-18T10:00:00Z"), UUID.randomUUID(),
- List.of(regra), null);
+ null, List.of(regra), null);
}
@ParameterizedTest
diff --git a/api/src/test/java/synapse/api/job/HashDaRegraTests.java b/api/src/test/java/synapse/api/job/HashDaRegraTests.java
index 904d853..2aa1bc3 100644
--- a/api/src/test/java/synapse/api/job/HashDaRegraTests.java
+++ b/api/src/test/java/synapse/api/job/HashDaRegraTests.java
@@ -1,11 +1,49 @@
package synapse.api.job;
import org.junit.jupiter.api.Test;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.json.JsonMapper;
import static org.assertj.core.api.Assertions.assertThat;
class HashDaRegraTests {
+ private static final JsonMapper JSON = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .build();
+
+ @Test
+ void hashIncluiCamposExtensiveisSemPerderPrecisao() {
+ String especificacoes = """
+ [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo",
+ "valor":3500.1234567890123456789},"extensao":{"ativo":true}}]
+ """;
+ String hash = HashDaRegra.calcular(comEspecificacoes(especificacoes));
+ assertThat(hash).isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("true", "false"))))
+ .isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("6789", "6788"))));
+ }
+
+ @Test
+ void ordemDeCamposExtensiveisNaoCriaUmaRepresentacaoDiferente() {
+ String primeira = """
+ [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo","valor":3500.125},
+ "extensao":{"criterios":["a","b"],"ativo":true}}]
+ """;
+ String equivalente = """
+ [{"extensao":{"ativo":true,"criterios":["a","b"]},"efeito":{"valor":3500.125,"tipo":"bonus_fixo"},
+ "construto":"faixa_valor","ref":"elem.1"}]
+ """;
+ assertThat(HashDaRegra.calcular(comEspecificacoes(primeira)))
+ .isEqualTo(HashDaRegra.calcular(comEspecificacoes(equivalente)));
+ }
+
+ private static RepresentacaoRegraDto comEspecificacoes(String especificacoes) {
+ var nucleo = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao().nucleo();
+ return JSON.readValue(
+ "{\"nucleo\":" + JSON.writeValueAsString(nucleo) + ",\"especificacoes\":" + especificacoes + "}",
+ RepresentacaoRegraDto.class);
+ }
+
@Test
void ignoraOrdemDasPropriedadesEscalaDecimalEConteudoForaDaRegra() {
String original = CriarJobControllerTests.FORMULARIO;
diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java
new file mode 100644
index 0000000..3370d5b
--- /dev/null
+++ b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java
@@ -0,0 +1,131 @@
+package synapse.api.job;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import tools.jackson.databind.json.JsonMapper;
+
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class ReprocessarJobControllerTests {
+
+ private static final UUID ORIGEM = UUID.randomUUID();
+
+ private static final UUID NOVO = UUID.randomUUID();
+
+ private final ReprocessarJobService service = mock(ReprocessarJobService.class);
+
+ private MockMvc mvc;
+
+ @BeforeEach
+ void preparar() {
+ this.mvc = MockMvcBuilders.standaloneSetup(new ReprocessarJobController(this.service))
+ .setControllerAdvice(new ReprocessarJobAdvice())
+ .build();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "", "{}", "{\"orcamento\":null,\"competencias\":null}" })
+ void corpoOpcionalCriaJobComProcedenciaERegra(String corpo) throws Exception {
+ when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob());
+ var pedido = post("/jobs/{id}/reprocessar", ORIGEM);
+ if (!corpo.isEmpty()) {
+ pedido.contentType(MediaType.APPLICATION_JSON).content(corpo);
+ }
+ String resposta = this.mvc.perform(pedido)
+ .andExpect(status().isCreated())
+ .andExpect(header().string("Location", "/api/jobs/" + NOVO))
+ .andExpect(jsonPath("$.id").value(NOVO.toString()))
+ .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros"))
+ .andExpect(jsonPath("$.origem").value("reprocessamento"))
+ .andExpect(jsonPath("$.job_origem_id").value(ORIGEM.toString()))
+ .andExpect(jsonPath("$.regra.versao").value(1))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ assertThat(new JsonMapper().readTree(resposta).propertyNames()).containsExactlyInAnyOrder("id", "status",
+ "origem", "competencias", "orcamento", "criado_em", "job_origem_id", "regra");
+ verify(this.service).reprocessar(eq(ORIGEM), argThat(r -> r.orcamento() == null && r.competencias() == null));
+ }
+
+ @Test
+ void overridesPreservamPrecisaoEOrdenamCompetencias() throws Exception {
+ when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob());
+ this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content("""
+ {"orcamento":-0.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]}
+ """)).andExpect(status().isCreated());
+ verify(this.service).reprocessar(eq(ORIGEM),
+ argThat(r -> r.orcamento() != null
+ && new BigDecimal("-0.1234567890123456789").compareTo(r.orcamento()) == 0
+ && List.of("2025-07", "2025-09", "2025-12").equals(r.competencias())));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "{", "null", "[]", "42", "{} {}", "{\"orcamento\":\"10\"}", "{\"competencias\":[]}",
+ "{\"competencias\":\"2025-11\"}", "{\"competencias\":[null]}", "{\"competencias\":[202511]}",
+ "{\"competencias\":[\"2025-06\"]}", "{\"competencias\":[\"2026-01\"]}", "{\"competencias\":[\"2025-7\"]}",
+ "{\"competencias\":[\"2025-11\",\"2025-11\"]}" })
+ void entradaInvalidaNaoChamaServico(String corpo) throws Exception {
+ this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content(corpo))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.codigo").value("requisicao_invalida"));
+ verifyNoInteractions(this.service);
+ }
+
+ @Test
+ void inexistenteRetorna404() throws Exception {
+ when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new JobNaoEncontradoException(ORIGEM));
+ this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.codigo").value("job_nao_encontrado"));
+ }
+
+ @Test
+ void estadoInvalidoRetorna409Especifico() throws Exception {
+ when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(ReprocessarJobException.estadoInvalido());
+ this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.codigo").value("estado_invalido"))
+ .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado."));
+ }
+
+ @Test
+ void naoExpoeErroDoJdbc() throws Exception {
+ when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new DataIntegrityViolationException("detalhe SQL"));
+ this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM))
+ .andExpect(status().isInternalServerError())
+ .andExpect(content().string(""));
+ }
+
+ private static JobCriadoDto novoJob() {
+ var representacao = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao();
+ Instant agora = Instant.parse("2026-09-23T10:00:00Z");
+ return new JobCriadoDto(NOVO, "aguardando_confirmacao_parametros", "reprocessamento", List.of("2025-11"),
+ new BigDecimal("485000"), agora, null, ORIGEM,
+ new RegraCriadaDto(UUID.randomUUID(), 1, "reprocessamento", representacao, agora));
+ }
+
+}
diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java
new file mode 100644
index 0000000..56d491a
--- /dev/null
+++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java
@@ -0,0 +1,519 @@
+package synapse.api.job;
+
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import javax.sql.DataSource;
+
+import liquibase.Contexts;
+import liquibase.Liquibase;
+import liquibase.database.DatabaseFactory;
+import liquibase.database.jvm.JdbcConnection;
+import liquibase.resource.ClassLoaderResourceAccessor;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.postgresql.PostgreSQLContainer;
+import tools.jackson.databind.DeserializationFeature;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.node.ObjectNode;
+
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Import;
+import org.springframework.dao.DataAccessException;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import synapse.api.core.outbox.Outbox;
+import synapse.api.core.security.AcessoDoUsuario;
+import synapse.api.core.security.UsuarioAtual;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@EnabledIf("dockerIsAvailable")
+class ReprocessarJobPersistenciaTests {
+
+ private static final UUID USUARIO = CriarJobControllerTests.USUARIO;
+
+ private static final UUID DONO_ORIGINAL = UUID.fromString("99999999-9999-4999-8999-999999999999");
+
+ private static final JsonMapper JSON = JsonMapper.builder()
+ .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
+ .build();
+
+ private static final String ESPECIFICACOES = """
+ [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789,
+ "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125},
+ "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}]
+ """;
+
+ private static PostgreSQLContainer postgres;
+
+ private static AnnotationConfigApplicationContext contexto;
+
+ private static JdbcTemplate jdbc;
+
+ private static JdbcTemplate dono;
+
+ private static ReprocessarJobService service;
+
+ private static MockMvc mvc;
+
+ static boolean dockerIsAvailable() {
+ return DockerClientFactory.instance().isDockerAvailable();
+ }
+
+ @BeforeAll
+ static void preparar() throws Exception {
+ postgres = new PostgreSQLContainer("postgres:18-alpine");
+ postgres.start();
+ try (Connection conexao = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(),
+ postgres.getPassword());
+ Liquibase liquibase = new Liquibase("db/changelog/changelog.yaml", new ClassLoaderResourceAccessor(),
+ DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conexao)))) {
+ liquibase.getChangeLogParameters().set("usuario_api", "synapse_api");
+ liquibase.getChangeLogParameters().set("usuario_codegen", "synapse_codegen");
+ liquibase.getChangeLogParameters().set("usuario_worker", "synapse_worker");
+ liquibase.update(new Contexts());
+ }
+ dono = new JdbcTemplate(
+ new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword()));
+ dono.execute("ALTER ROLE synapse_api WITH PASSWORD 'senha-de-teste'");
+ DataSource dataSource = new DriverManagerDataSource(postgres.getJdbcUrl(), "synapse_api", "senha-de-teste");
+ jdbc = new JdbcTemplate(dataSource);
+ jdbc.update("""
+ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em)
+ VALUES (?, 'rh-t079', 'x', 'RH', 'profissional_rh', '2026-01-02T00:00:00Z')
+ """, USUARIO);
+ contexto = new AnnotationConfigApplicationContext();
+ contexto.registerBean(DataSource.class, () -> dataSource);
+ contexto.registerBean(JdbcTemplate.class, () -> jdbc);
+ contexto.registerBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(dataSource));
+ contexto.register(Config.class);
+ contexto.refresh();
+ service = contexto.getBean(ReprocessarJobService.class);
+ UsuarioAtual usuarioAtual = mock(UsuarioAtual.class);
+ when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(DONO_ORIGINAL, false));
+ mvc = MockMvcBuilders
+ .standaloneSetup(contexto.getBean(ReprocessarJobController.class),
+ new BuscarJobController(contexto.getBean(BuscarJobService.class),
+ contexto.getBean(AutorizadorDeJob.class), usuarioAtual),
+ contexto.getBean(ConfirmarParametrosController.class))
+ .setControllerAdvice(new ReprocessarJobAdvice(), new ConfirmarParametrosAdvice())
+ .build();
+ }
+
+ @AfterAll
+ static void encerrar() {
+ if (contexto != null) {
+ contexto.close();
+ }
+ if (postgres != null) {
+ postgres.stop();
+ }
+ }
+
+ @TestConfiguration(proxyBeanMethods = false)
+ @EnableTransactionManagement
+ @Import({ CriarJobService.class, ReprocessarJobService.class, BuscarJobService.class,
+ ConfirmarParametrosService.class, ExecutarAcaoService.class, MaquinaDeEstadosDoJob.class, Outbox.class,
+ ReprocessarJobController.class, AutorizadorDeJob.class, ConfirmarParametrosController.class })
+ static class Config {
+
+ }
+
+ @BeforeEach
+ void limpar() {
+ dono.execute("TRUNCATE submissoes CASCADE");
+ jdbc.update("""
+ INSERT INTO usuarios (id, login, senha_hash, nome, papel, ativo, criado_em)
+ VALUES (?, 'dono-t079', 'x', 'Dono original', 'profissional_rh', false, '2026-02-01T00:00:00Z')
+ ON CONFLICT (id) DO NOTHING
+ """, DONO_ORIGINAL);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "", "{}" })
+ void criaNovoJobHerdandoParametrosEUltimaRegraSemAlterarOriginal(String corpo) throws Exception {
+ UUID origem = criarOrigem(true);
+ String antes = retrato(origem);
+ JsonNode novo = reprocessar(origem, corpo);
+ UUID novoId = UUID.fromString(novo.path("id").asString());
+ assertThat(novoId).isNotEqualTo(origem);
+ assertThat(novo.propertyNames()).containsExactlyInAnyOrder("id", "status", "origem", "competencias",
+ "orcamento", "criado_em", "job_origem_id", "regra");
+ assertThat(novo.path("status").asString()).isEqualTo("aguardando_confirmacao_parametros");
+ assertThat(novo.path("origem").asString()).isEqualTo("reprocessamento");
+ assertThat(novo.path("job_origem_id").asString()).isEqualTo(origem.toString());
+ assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("485000.1234567890123456789");
+ assertThat(novo.path("competencias")).isEqualTo(JSON.readTree("[\"2025-11\"]"));
+ Map job = jdbc.queryForMap("SELECT * FROM jobs WHERE id = ?", novoId);
+ assertThat(job).containsEntry("usuario_id", DONO_ORIGINAL)
+ .containsEntry("submissao_id", null)
+ .containsEntry("job_origem_id", origem)
+ .containsEntry("status", "aguardando_confirmacao_parametros")
+ .containsEntry("tentativas", 0)
+ .containsEntry("iniciado_em", null)
+ .containsEntry("finalizado_em", null);
+ assertThat(job.get("orcamento")).isEqualTo(new BigDecimal("485000.1234567890123456789"));
+ assertThat(competencias(novoId)).containsExactlyElementsOf(competencias(origem));
+ assertThat(jdbc.queryForObject("SELECT count(*) FROM job_transicoes WHERE job_id = ?", Integer.class, novoId))
+ .isEqualTo(1);
+ assertThat(jdbc.queryForMap("SELECT status_anterior, status_novo, ator FROM job_transicoes WHERE job_id = ?",
+ novoId))
+ .containsEntry("status_anterior", null)
+ .containsEntry("status_novo", "aguardando_confirmacao_parametros")
+ .containsEntry("ator", "usuario");
+ Map anterior = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 7", origem);
+ Map regra = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId);
+ assertThat(regra).containsEntry("versao", 1)
+ .containsEntry("origem", "reprocessamento")
+ .containsEntry("regra_origem_id", anterior.get("id"))
+ .containsEntry("nucleo", anterior.get("nucleo"))
+ .containsEntry("especificacoes", anterior.get("especificacoes"))
+ .containsEntry("hash", anterior.get("hash"))
+ .containsEntry("criada_em", job.get("criado_em"));
+ assertThat(regra.get("id")).isNotEqualTo(anterior.get("id"));
+ assertThat(novo.path("regra").path("id").asString())
+ .isEqualTo(Objects.requireNonNull(regra.get("id")).toString());
+ assertThat(novo.path("regra").path("versao").asInt()).isEqualTo(1);
+ assertThat(novo.path("regra").path("representacao").path("especificacoes"))
+ .isEqualTo(JSON.readTree(ESPECIFICACOES));
+ assertThat(JSON.readTree(Objects.requireNonNull(regra.get("especificacoes")).toString()))
+ .isEqualTo(JSON.readTree(ESPECIFICACOES));
+ verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), List.of("2025-11"));
+ assertThat(retrato(origem)).isEqualTo(antes);
+ assertThat(jdbc.queryForObject("SELECT count(*) FROM submissoes", Integer.class)).isEqualTo(1);
+ }
+
+ @Test
+ void overridesMudamSomenteONovoJobESeuEvento() throws Exception {
+ UUID origem = criarOrigem(true);
+ String antes = retrato(origem);
+ JsonNode novo = reprocessar(origem, """
+ {"orcamento":600000.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]}
+ """);
+ UUID novoId = UUID.fromString(novo.path("id").asString());
+ assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("600000.1234567890123456789");
+ assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId))
+ .isEqualByComparingTo("600000.1234567890123456789");
+ assertThat(competencias(novoId)).containsExactly("2025-07", "2025-09", "2025-12");
+ verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), competencias(novoId));
+ assertThat(retrato(origem)).isEqualTo(antes);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "{\"orcamento\":-1.125}", "{\"competencias\":[\"2025-07\"]}" })
+ void overrideIsoladoPreservaOutroParametro(String corpo) throws Exception {
+ UUID origem = criarOrigem(false);
+ JsonNode novo = reprocessar(origem, corpo);
+ boolean mudaOrcamento = corpo.contains("orcamento");
+ assertThat(novo.path("orcamento").decimalValue())
+ .isEqualByComparingTo(mudaOrcamento ? "-1.125" : "485000.1234567890123456789");
+ assertThat(novo.path("competencias"))
+ .isEqualTo(JSON.readTree(mudaOrcamento ? "[\"2025-11\"]" : "[\"2025-07\"]"));
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = JobStatus.class, names = "ARQUIVADO", mode = EnumSource.Mode.EXCLUDE)
+ void recusaTodosOsDemaisEstadosSemEfeitos(JobStatus estado) throws Exception {
+ UUID origem = criarOrigem(false);
+ jdbc.update("UPDATE jobs SET status = ? WHERE id = ?", estado.paraColuna(), origem);
+ Map antes = contagens();
+ String original = retrato(origem);
+ mvc.perform(post("/jobs/{id}/reprocessar", origem))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.codigo").value("estado_invalido"))
+ .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado."));
+ assertThat(contagens()).isEqualTo(antes);
+ assertThat(retrato(origem)).isEqualTo(original);
+ }
+
+ @Test
+ void inexistenteNaoTemEfeitosColaterais() throws Exception {
+ Map antes = contagens();
+ mvc.perform(post("/jobs/{id}/reprocessar", UUID.randomUUID()))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.codigo").value("job_nao_encontrado"));
+ assertThat(contagens()).isEqualTo(antes);
+ }
+
+ @Test
+ void arquivadoSemRegraNaoCriaJobParcial() throws Exception {
+ UUID origem = criarOrigem(false);
+ dono.update("DELETE FROM trilhas_auditoria WHERE job_id = ?", origem);
+ dono.update("DELETE FROM regras WHERE job_id = ?", origem);
+ Map antes = contagens();
+ mvc.perform(post("/jobs/{id}/reprocessar", origem))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.codigo").value("estado_invalido"))
+ .andExpect(
+ jsonPath("$.mensagem").value("O job arquivado não possui uma regra formada para reprocessamento."));
+ assertThat(contagens()).isEqualTo(antes);
+ }
+
+ @Test
+ void getPosteriorEncontraJobSemSubmissaoEComRegraSemeada() throws Exception {
+ UUID origem = criarOrigem(true);
+ JsonNode novo = reprocessar(origem, "");
+ String resposta = assertDoesNotThrow(() -> mvc.perform(get("/jobs/{id}", novo.path("id").asString())))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.origem").value("reprocessamento"))
+ .andExpect(jsonPath("$.job_origem_id").value(origem.toString()))
+ .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros"))
+ .andExpect(jsonPath("$.regras.length()").value(1))
+ .andExpect(jsonPath("$.regras[0].versao").value(1))
+ .andExpect(jsonPath("$.regras[0].id").value(novo.path("regra").path("id").asString()))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ assertThat(JSON.readTree(resposta).has("submissao_id")).isFalse();
+ assertThat(JSON.readTree(resposta).path("regras").path(0).path("representacao").path("especificacoes"))
+ .isEqualTo(JSON.readTree(ESPECIFICACOES));
+ mvc.perform(get("/jobs/{id}", origem))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.origem").value("formulario"))
+ .andExpect(jsonPath("$.submissao_id").isNotEmpty())
+ .andExpect(jsonPath("$.job_origem_id").doesNotExist());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "nenhuma", "nucleo", "especificacoes", "ambos", "parametros", "adicionar", "remover" })
+ void confirmaRepresentacaoDoGetPreservandoVersoesEOriginal(String edicao) throws Exception {
+ UUID origem = criarOrigem(true);
+ String original = retrato(origem);
+ JsonNode novo = reprocessar(origem, "");
+ UUID novoId = UUID.fromString(novo.path("id").asString());
+ Map semeada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId);
+ JsonNode representacaoOriginal = consultar(novoId).path("regras").path(0).path("representacao");
+ ObjectNode enviada = (ObjectNode) representacaoOriginal.deepCopy();
+ boolean mudaNucleo = edicao.equals("nucleo") || edicao.equals("ambos");
+ boolean mudaEspecificacoes = List.of("especificacoes", "ambos", "adicionar", "remover").contains(edicao);
+ boolean editado = mudaNucleo || mudaEspecificacoes;
+ if (mudaNucleo) {
+ ((ObjectNode) enviada.path("nucleo")).put("percentual", new BigDecimal("0.04"));
+ }
+ if (edicao.equals("especificacoes") || edicao.equals("ambos")) {
+ ((ObjectNode) enviada.path("especificacoes").path(0).path("efeito")).put("valor",
+ new BigDecimal("3500.123456789012345678901"));
+ }
+ if (edicao.equals("adicionar")) {
+ enviada.withArray("especificacoes").add(JSON.readTree("""
+ {"ref":"elem.2","construto":"generico","descricao":"bonus de aniversario",
+ "campos":{"fator":0.123456789012345678901}}
+ """));
+ }
+ if (edicao.equals("remover")) {
+ enviada.putArray("especificacoes");
+ }
+ ObjectNode pedido = JSON.createObjectNode();
+ pedido.set("regra", enviada);
+ if (edicao.equals("parametros")) {
+ pedido.put("orcamento", new BigDecimal("700000.123456789012345678901"));
+ pedido.set("competencias", JSON.readTree("[\"2025-10\",\"2025-08\"]"));
+ }
+ String corpo = JSON.writeValueAsString(pedido);
+ assertThat(JSON.valueToTree(ConfirmarParametrosRequisicao.deJson(corpo).representacao()))
+ .isEqualTo(enviada);
+ String resposta = mvc
+ .perform(post("/jobs/{id}/parameters", novoId).contentType(MediaType.APPLICATION_JSON).content(corpo))
+ .andExpect(status().isAccepted())
+ .andExpect(jsonPath("$.status").value("gerando_regra"))
+ .andExpect(jsonPath("$.origem").value("reprocessamento"))
+ .andExpect(jsonPath("$.job_origem_id").value(origem.toString()))
+ .andExpect(jsonPath("$.regra.versao").value(editado ? 2 : 1))
+ .andExpect(jsonPath("$.regra.origem").value(editado ? "confirmacao_usuario" : "reprocessamento"))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ JsonNode confirmado = JSON.readTree(resposta);
+ assertThat(confirmado.has("submissao_id")).isFalse();
+ assertThat(confirmado.path("regra").path("representacao")).isEqualTo(enviada);
+ assertThat(jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 1", novoId)).isEqualTo(semeada);
+ assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, novoId))
+ .isEqualTo("gerando_regra");
+ assertThat(jdbc.queryForObject("SELECT count(*) FROM regras WHERE job_id = ?", Integer.class, novoId))
+ .isEqualTo(editado ? 2 : 1);
+ Map utilizada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = ?", novoId,
+ editado ? 2 : 1);
+ assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("nucleo")).toString()))
+ .isEqualTo(enviada.path("nucleo"));
+ assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("especificacoes")).toString()))
+ .isEqualTo(enviada.path("especificacoes"));
+ assertThat(Objects.requireNonNull(utilizada.get("id")).toString())
+ .isEqualTo(confirmado.path("regra").path("id").asString());
+ assertThat(utilizada.get("hash"))
+ .isEqualTo(HashDaRegra.calcular(ConfirmarParametrosRequisicao.deJson(corpo).representacao()));
+ if (editado) {
+ assertThat(utilizada.get("regra_origem_id")).isEqualTo(semeada.get("id"));
+ assertThat(utilizada.get("hash")).isNotEqualTo(semeada.get("hash"));
+ }
+ else {
+ assertThat(utilizada).isEqualTo(semeada);
+ }
+ if (edicao.equals("parametros")) {
+ assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId))
+ .isEqualByComparingTo("700000.123456789012345678901");
+ assertThat(competencias(novoId)).containsExactly("2025-08", "2025-10");
+ assertThat(confirmado.path("orcamento")).isEqualTo(pedido.path("orcamento"));
+ assertThat(confirmado.path("competencias")).isEqualTo(JSON.readTree("[\"2025-08\",\"2025-10\"]"));
+ }
+ else {
+ assertThat(confirmado.path("orcamento")).isEqualTo(novo.path("orcamento"));
+ assertThat(confirmado.path("competencias")).isEqualTo(novo.path("competencias"));
+ }
+ JsonNode regras = consultar(novoId).path("regras");
+ assertThat(regras.size()).isEqualTo(editado ? 2 : 1);
+ assertThat(regras.path(0).path("representacao")).isEqualTo(representacaoOriginal);
+ assertThat(regras.path(editado ? 1 : 0).path("representacao")).isEqualTo(enviada);
+ String payload = Objects.requireNonNull(jdbc.queryForObject(
+ "SELECT payload::text FROM outbox_events WHERE job_id = ? AND tipo = 'parametros-confirmados'",
+ String.class, novoId));
+ ContratoDeEvento.validar("parametros-confirmados", payload);
+ assertThat(JSON.readTree(payload).path("regra_id")).isEqualTo(confirmado.path("regra").path("id"));
+ JsonNode trilha = JSON.readTree(Objects.requireNonNull(jdbc.queryForObject(
+ "SELECT conclusao::text FROM trilhas_auditoria WHERE job_id = ? AND no = 'confirmacao'", String.class,
+ novoId)));
+ assertThat(trilha.path("editado_pelo_usuario").asBoolean()).isEqualTo(editado);
+ List corrigidos = new ArrayList<>();
+ if (mudaNucleo) {
+ corrigidos.add("nucleo.percentual");
+ }
+ if (mudaEspecificacoes) {
+ corrigidos.add(edicao.equals("adicionar") ? "elem.2" : "elem.1");
+ }
+ assertThat(trilha.path("campos_corrigidos")).isEqualTo(JSON.valueToTree(corrigidos));
+ assertThat(trilha.path("resumo").asString())
+ .isEqualTo(editado ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar"
+ : "usuário confirmou os parâmetros");
+ assertThat(retrato(origem)).isEqualTo(original);
+ }
+
+ private static JsonNode consultar(UUID jobId) throws Exception {
+ return JSON.readTree(mvc.perform(get("/jobs/{id}", jobId))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString());
+ }
+
+ @Test
+ void falhaNoOutboxReverteJobRegraETransicao() {
+ UUID origem = criarOrigem(true);
+ Map antes = contagens();
+ String original = retrato(origem);
+ dono.execute("REVOKE INSERT ON outbox_events FROM synapse_api");
+ try {
+ assertThatThrownBy(() -> service.reprocessar(origem, ReprocessarJobRequisicao.deJson(null)))
+ .isInstanceOf(DataAccessException.class)
+ .hasMessageContaining("INSERT INTO outbox_events");
+ }
+ finally {
+ dono.execute("GRANT INSERT ON outbox_events TO synapse_api");
+ }
+ assertThat(contagens()).isEqualTo(antes);
+ assertThat(retrato(origem)).isEqualTo(original);
+ }
+
+ private static UUID criarOrigem(boolean extensoes) {
+ JobCriadoDto job = contexto.getBean(CriarJobService.class)
+ .criar(CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO));
+ var representacao = ConfirmarParametrosRequisicao.deJson(ConfirmarParametrosControllerTests.CONFIRMAR)
+ .representacao();
+ var completa = JSON.readValue("{\"nucleo\":" + JSON.writeValueAsString(representacao.nucleo())
+ + ",\"especificacoes\":" + (extensoes ? ESPECIFICACOES : "[]") + "}", RepresentacaoRegraDto.class);
+ UUID regraId = Objects.requireNonNull(jdbc.queryForObject("""
+ INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em)
+ VALUES (?, 7, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, now()) RETURNING id
+ """, UUID.class, job.id(), job.regra().id(), JSON.writeValueAsString(representacao.nucleo()),
+ extensoes ? ESPECIFICACOES : "[]", HashDaRegra.calcular(completa)));
+ var maquina = contexto.getBean(MaquinaDeEstadosDoJob.class);
+ maquina.transicionar(job.id(), JobStatus.SIMULANDO, "evento", null);
+ maquina.transicionar(job.id(), JobStatus.AGUARDANDO_DECISAO_USUARIO, "evento", null);
+ contexto.getBean(ExecutarAcaoService.class).aplicar(job.id(), AcaoJob.ARQUIVAR);
+ jdbc.update("UPDATE jobs SET usuario_id = ?, tentativas = 3, iniciado_em = criado_em WHERE id = ?",
+ DONO_ORIGINAL, job.id());
+ jdbc.update("""
+ INSERT INTO trilhas_auditoria (evento_id, job_id, no, concluido_em, regra_id, conclusao)
+ VALUES (?, ?, 'confirmacao', now(), ?, '{"resumo":"confirmação anterior"}'::jsonb)
+ """, UUID.randomUUID(), job.id(), regraId);
+ return job.id();
+ }
+
+ private static JsonNode reprocessar(UUID origem, String corpo) throws Exception {
+ var pedido = post("/jobs/{id}/reprocessar", origem);
+ if (!corpo.isEmpty()) {
+ pedido.contentType(MediaType.APPLICATION_JSON).content(corpo);
+ }
+ var resposta = mvc.perform(pedido).andExpect(status().isCreated()).andReturn().getResponse();
+ JsonNode novo = JSON.readTree(resposta.getContentAsString());
+ assertThat(resposta.getHeader("Location")).isEqualTo("/api/jobs/" + novo.path("id").asString());
+ return novo;
+ }
+
+ private static void verificarEvento(UUID jobId, UUID regraId, List competencias) throws Exception {
+ Map evento = jdbc
+ .queryForMap("SELECT tipo, payload::text AS payload FROM outbox_events WHERE job_id = ?", jobId);
+ assertThat(evento).containsEntry("tipo", "regra-submetida");
+ String payload = Objects.requireNonNull((String) evento.get("payload"));
+ ContratoDeEvento.validar("regra-submetida", payload);
+ assertThat(JSON.readTree(payload)).isEqualTo(JSON.valueToTree(Map.of("job_id", jobId.toString(), "origem",
+ "reprocessamento", "competencias", competencias, "regra_id", regraId.toString())));
+ }
+
+ private static List competencias(UUID jobId) {
+ return jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId);
+ }
+
+ private static Map contagens() {
+ return jdbc.queryForMap("""
+ SELECT (SELECT count(*) FROM jobs) AS jobs, (SELECT count(*) FROM regras) AS regras,
+ (SELECT count(*) FROM submissoes) AS submissoes, (SELECT count(*) FROM outbox_events) AS eventos,
+ (SELECT count(*) FROM job_transicoes) AS transicoes, (SELECT count(*) FROM job_acoes) AS acoes,
+ (SELECT count(*) FROM trilhas_auditoria) AS trilhas
+ """);
+ }
+
+ private static String retrato(UUID jobId) {
+ return Objects.requireNonNull(jdbc.queryForObject(
+ """
+ SELECT jsonb_build_object('job', to_jsonb(j), 'submissao', to_jsonb(s),
+ 'regras', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM regras r WHERE r.job_id = j.id),
+ 'transicoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM job_transicoes t WHERE t.job_id = j.id),
+ 'acoes', (SELECT jsonb_agg(to_jsonb(a) ORDER BY a.id) FROM job_acoes a WHERE a.job_id = j.id),
+ 'trilhas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM trilhas_auditoria t WHERE t.job_id = j.id),
+ 'eventos', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id) FROM outbox_events e WHERE e.job_id = j.id),
+ 'simulacoes', (SELECT jsonb_agg(to_jsonb(s) ORDER BY s.id) FROM simulacoes s WHERE s.job_id = j.id))::text
+ FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id WHERE j.id = ?
+ """,
+ String.class, jobId));
+ }
+
+}