Criação de Módulos Backend no ASY5
Manual técnico exaustivo e profissional para desenvolvimento de novos serviços de negócio de raiz, consolidando os padrões do módulo de Inspeção Aduaneira (angola/inspection) e dos módulos centrais da plataforma (assignment, declaration, accounting).
01 Visão Geral da Arquitetura ASY5
O backend do ASY5 não é uma aplicação monolítica convencional nem um conjunto de microsserviços desacoplados via REST comum. Ele opera sobre o ASYCUDA Cloud Framework (ACF) em cima do ecossistema Quarkus, estruturado como um monorepo gerido por Gradle Kotlin DSL.
O padrão primordial do sistema é o Document Workflow Engine. Um documento aduaneiro (Declaração, Exit Note, Agendamento de Inspeção, Liquidação) é modelado como uma máquina de estados estrita, em que:
🔒 Segurança por Design
O acesso não é gerido por rotas HTTP simples, mas sim por @Unit e @Role vinculados ao SSO/Keycloak, combinados com @DocumentRestriction em tempo de execução para estâncias e NIFs.
⚙️ Geração em Compilação
Anotações como @AutoModel e @Document disparam processadores de código que constroem DTOs, mappers, interfaces de fluxo e atividades antes mesmo da compilação dos serviços.
📦 Fluxos Reutilizáveis
As operações são encapsuladas em FlowInstance encadeando classes unitárias de validação (DocumentValidationActivity), garantindo manutenibilidade e isolamento de testes.
02 Anatomia Completa de um Módulo
Os módulos residem em diretórios definidos pelo escopo de domínio:
customs/: Módulos aduaneiros globais do produto base (ex:declaration,assignment,tariff).angola/: Customizações e especificidades da AGT / Angola (ex:angola/inspection,angola/webservice).core/: Serviços fundamentais transversais (ex:core/catalogs,core/security).
| Subcamada | Nome Gradle Convenção | Plugin Gradle | Responsabilidade Principal |
|---|---|---|---|
raiz (Aplicação) |
:angola:inspection |
plugins-as-application |
Ponto de arranque Quarkus, injeção de dependências CDI, rotas REST, consumidores Kafka. |
dm |
:angola:inspection:inspection-dm |
plugins-as-library |
Modelos puros de domínio, enums agnósticos, DTOs partilháveis sem dependências pesadas. |
entities |
:angola:inspection:inspection-entities |
plugins-as-library |
Entidades JPA estendendo DocumentEntity, anotações @AutoModel para gerar DTOs. |
spec |
:angola:inspection:inspection-spec |
plugins-as-library |
Contrato funcional: @Document, @Status, @Operation, @Unit e @Role. |
api |
:angola:inspection:inspection-api |
plugins-as-library |
Interfaces Java e clientes partilhados consumidos por outros módulos no monorepo. |
grpc |
:angola:inspection:inspection-grpc |
plugins-as-proto |
Definições .proto e stubs gRPC de alta performance inter-módulo. |
liquibase |
N/A (pasta de recursos) | N/A | Changelogs de base de dados em formato JSON versionado. |
03 Matriz Comparativa: O Que Outros Módulos Nos Ensinam
Para além do módulo de Inspeção, outros módulos fornecem peças vitais de arquitetura:
| Módulo | Pontos Fortes & Padrões Únicos | Quando Seguir Como Exemplo |
|---|---|---|
angola/inspection |
Workflow com negociação de estados (Reagendamentos), validação de NIF OEA, restrições combinadas por estância e operador declarante, integração com contentores e itens. | Para qualquer módulo que requer máquina de estados bidirecional com despachantes e técnicos, auditoria rigorosa e formulários com anexos. |
customs/assignment |
Integração profunda de gRPC (assignment-grpc com Assignment.proto), escalonador de tarefas (useScheduler(true)), algoritmos puros desacoplados. |
Quando o seu módulo precisa de ser invocado sincronicamente em alta frequência por outros serviços (ex: cálculo de atribuição, quotas). |
customs/declaration |
Integração abrangente com eventos Kafka (@Incoming("risk"), @Incoming("settlements")), relatórios Jasper, catálogo de tarifas. |
Para módulos reativos assíncronos que processam eventos emitidos por outros departamentos ou pelo motor de risco aduaneiro. |
customs/accounting |
Liquidações complexas, regras financeiras estritas, reversão e cancelamento de transações. | Para fluxos com operações financeiras e conciliações bancárias. |
04 Passo a Passo Prático de Criação do Módulo
Crie a árvore de diretórios (exemplo com o módulo customs/valuation ou angola/meumodulo):
mkdir -p customs/meumodulo/{dm,entities,spec,api,grpc,liquibase/v1.0}
mkdir -p customs/meumodulo/src/main/{java/org/asycuda/cloud/customs/meumodulo,resources}
mkdir -p customs/meumodulo/src/test/java/org/asycuda/cloud/customs/meumodulo
Configure o customs/meumodulo/build.gradle.kts principal da aplicação:
plugins {
id("org.asycuda.cloud.framework.plugins-as-application")
}
group = "org.asycuda.cloud.customs"
description = "ASYCUDA v5 Customs Valuation Module"
dependencies {
asycuda.layers {
useLayerDocumentWorkflow() // Ativa motor documental e anotações ACF
useLayerGrasp() // Regras de validação avançadas
mergeLayers()
}
asycuda.modules {
api(serialApi()) // Gerador de número de série oficial
}
asycuda.platforms {
implementation(quarkusSmallryeReactiveMessagingKafka())
implementation(quarkusSmallryeStork())
implementation(smallryeStorkServiceDiscoveryStaticList())
}
// Submódulos locais do próprio módulo
api(project(":customs:meumodulo:meumodulo-dm"))
api(project(":customs:meumodulo:meumodulo-entities"))
api(project(":customs:meumodulo:meumodulo-spec"))
// Bibliotecas partilhadas do core ASY5
api(project(":shared:data-model"))
api(project(":shared:utils:decimals"))
api(project(":core:catalogs")) {
capabilities {
requireCapability("org.asycuda.cloud.customs:catalogs-dm")
}
}
}
E configure o entities/build.gradle.kts e spec/build.gradle.kts com plugins-as-library:
plugins {
id("org.asycuda.cloud.framework.plugins-as-library")
}
group = "org.asycuda.cloud.customs"
description = "ASYCUDA v5 Customs Valuation - Entities"
dependencies {
asycuda.layers {
useLayerDocumentWorkflow()
useConfigurator(true)
mergeLayers()
}
api(project(":shared:data-model"))
api(project(":customs:meumodulo:meumodulo-dm"))
}
settings.gradle.kts na raiz! O método getProjectPath detecta qualquer pasta que contenha um ficheiro build.gradle.kts válido.
O ASY5 padronizou as migrações em JSON compatíveis com Oracle e PostgreSQL:
{
"databaseChangeLog": [
{
"include": {
"file": "v1.0/001-VALUATION-init-schema.json",
"relativeToChangelogFile": true
}
}
]
}
{
"databaseChangeLog": [
{
"changeSet": {
"id": "INIT-VALUATION-1",
"author": "ASY5-Team",
"changes": [
{
"createTable": {
"tableName": "VALUATION_DOCUMENT",
"columns": [
{
"column": {
"name": "INSTANCE_ID",
"type": "VARCHAR2(255 CHAR)",
"constraints": { "primaryKey": true, "nullable": false }
}
},
{
"column": {
"name": "DOCUMENT_STATUS",
"type": "VARCHAR2(50 CHAR)",
"constraints": { "nullable": false }
}
},
{
"column": {
"name": "DECLARATION_REF",
"type": "VARCHAR2(100 CHAR)"
}
},
{
"column": {
"name": "CUSTOMS_OFFICE",
"type": "VARCHAR2(10 CHAR)"
}
},
{
"column": {
"name": "DECLARANT_CODE",
"type": "VARCHAR2(50 CHAR)"
}
},
{
"column": {
"name": "CREATED_DATE",
"type": "TIMESTAMP(6)"
}
}
]
}
}
]
}
}
]
}
Defina o pacote de destino dos DTOs em ModelNames.java:
package org.asycuda.cloud.customs.meumodulo.entities;
public interface ModelNames {
// O prefixo ":" avisa o processador de modelos a criar a classe neste pacote
String PACKAGE = ":org.asycuda.cloud.customs.meumodulo.dtos";
String M_CREATE = "create";
String M_UPDATE = "update";
}
Crie a entidade que representa o documento estendendo DocumentEntity<String>:
package org.asycuda.cloud.customs.meumodulo.entities;
import jakarta.persistence.*;
import lombok.*;
import org.asycuda.cloud.framework.commons.modelgen.annotations.AutoModel;
import org.asycuda.cloud.framework.extensions.document.entities.DocumentEntity;
import org.asycuda.cloud.framework.extensions.document.annotations.Document.properties;
import org.asycuda.cloud.framework.extensions.metadata.annotations.MetadataModel;
import org.asycuda.cloud.framework.extensions.metadata.annotations.MetadataProperty;
import static org.asycuda.cloud.customs.meumodulo.entities.ModelNames.*;
import static org.asycuda.cloud.framework.commons.modelgen.annotations.AutoModel.Policy.EXCLUDE;
@Entity
@Table(name = "VALUATION_DOCUMENT")
@AttributeOverride(
name = "id",
column = @Column(name = "INSTANCE_ID", updatable = false, nullable = false))
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = true)
// GERAÇÃO AUTOMÁTICA DOS DTOs PELO FRAMEWORK:
@AutoModel(copyAnnotations = false, keys = {"id", "version"}, targetPackageName = PACKAGE)
@AutoModel.NewField(name = "id", type = String.class)
@AutoModel.NewField(name = "version", type = Integer.class)
@AutoModel(ref = M_CREATE, targetPackageName = PACKAGE, policy = EXCLUDE, root = true)
@AutoModel(ref = M_UPDATE, targetPackageName = PACKAGE, policy = EXCLUDE, root = true)
@MetadataModel(
root = true,
properties = {
@MetadataProperty(key = properties.PROPERTY_DOCUMENT_TYPE, value = "valuation"),
@MetadataProperty(key = properties.PROPERTY_DOCUMENT_OPERATIONS, value = "L|ALL,STORE,SUBMIT,VALIDATE,REJECT")
})
public class ValuationDocumentEntity extends DocumentEntity<String> {
@Column(name = "DECLARATION_REF", nullable = false)
private String declarationReference;
@Column(name = "CUSTOMS_OFFICE", nullable = false)
private String customsOffice;
@Column(name = "DECLARANT_CODE", nullable = false)
private String declarantCode;
@Column(name = "ASSESSED_VALUE")
private Double assessedValue;
}
Na camada spec, declaram-se os estados, operações, perfis de segurança e o gestor documental.
package org.asycuda.cloud.customs.meumodulo.spec;
import org.asycuda.cloud.framework.extensions.security.annotations.ConnectId;
import org.asycuda.cloud.framework.extensions.security.annotations.Role;
import org.asycuda.cloud.framework.extensions.security.annotations.Unit;
import static org.asycuda.cloud.customs.meumodulo.spec.ValuationSecurityDefinition.*;
@Unit(
value = "valuation-officer",
description = "Oficial de Valoração Aduaneira",
roles = { VIEWING, PROCESSING, VALIDATING }
)
@Unit(
value = "valuation-declarant",
description = "Despachante que submete pedidos",
roles = { VIEWING, SUBMITTING }
)
@ConnectId
public interface ValuationSecurityDefinition {
@Role("Pesquisa e leitura de documentos")
String VIEWING = "valuation-viewing";
@Role("Submissão de declarações para valoração")
String SUBMITTING = "valuation-submitting";
@Role("Trabalho técnico de verificação")
String PROCESSING = "valuation-processing";
@Role("Aprovação e liquidação")
String VALIDATING = "valuation-validating";
}
package org.asycuda.cloud.customs.meumodulo.spec;
import org.asycuda.cloud.framework.extensions.document.annotations.Status;
import org.asycuda.cloud.framework.extensions.document.annotations.Status.StartStatus;
@Status
public enum ValuationStatus {
@StartStatus
NIL, // Estado inicial antes do primeiro save
STORED, // Rascunho
SUBMITTED, // Submetido para análise
IN_REVIEW, // Em análise pelo oficial
APPROVED, // Valor aprovado
REJECTED // Rejeitado
}
package org.asycuda.cloud.customs.meumodulo.spec;
import org.asycuda.cloud.customs.meumodulo.dtos.ValuationDocumentDtoView;
import org.asycuda.cloud.framework.extensions.document.annotations.Operation;
import org.asycuda.cloud.framework.extensions.document.annotations.OperationSecurity;
import static org.asycuda.cloud.framework.extensions.document.components.DocumentOperation.OperationType.CREATE;
import static org.asycuda.cloud.framework.extensions.document.components.DocumentOperation.OperationType.UPDATE;
import static org.asycuda.cloud.customs.meumodulo.spec.ValuationSecurityDefinition.*;
public enum ValuationOperation {
@Operation(
ordinal = 1,
type = CREATE,
alias = "submit",
sourceStatus = "NIL",
destinationStatus = "SUBMITTED",
startOperation = "NEW",
security = @OperationSecurity(roles = {SUBMITTING}),
icon = "$acf-granted",
output = ValuationDocumentDtoView.class,
summary = "Submeter pedido de valoração"
)
SUBMIT,
@Operation(
ordinal = 2,
type = UPDATE,
alias = "approve",
sourceStatus = "SUBMITTED",
destinationStatus = "APPROVED",
startOperation = "RETRIEVE",
security = @OperationSecurity(roles = {VALIDATING}),
icon = "$acf-done",
output = ValuationDocumentDtoView.class,
summary = "Aprovar valoração"
)
APPROVE
}
package org.asycuda.cloud.customs.meumodulo.spec;
import jakarta.persistence.EntityManager;
import org.asycuda.cloud.customs.meumodulo.dtos.ValuationDocumentDtoView;
import org.asycuda.cloud.customs.meumodulo.entities.ValuationDocumentEntity;
import org.asycuda.cloud.framework.extensions.document.UuidHibernateDocumentManager;
import org.asycuda.cloud.framework.extensions.document.annotations.Document;
import org.asycuda.cloud.framework.extensions.document.annotations.DocumentSecurity;
import org.asycuda.cloud.framework.extensions.document.annotations.DocumentTracker;
import static org.asycuda.cloud.customs.meumodulo.spec.ValuationSecurityDefinition.VIEWING;
@Document(
module = "valuation",
document = "valuationdoc",
name = "valuation",
description = "Gestor documental de valoração aduaneira",
gwEnums = {ValuationOperation.class, ValuationStatus.class},
useShortSubselect = true,
security = @DocumentSecurity(searchRoles = VIEWING, auditRoles = VIEWING, trackerRoles = VIEWING),
tracker = @DocumentTracker
)
public class ValuationDocument extends UuidHibernateDocumentManager<
ValuationStatus,
ValuationStartOperation,
ValuationOperation,
ValuationDocumentEntity,
ValuationContextData,
ValuationDocumentDtoView> {
public ValuationDocument(EntityManager entityManager) {
super(entityManager);
}
@Override
protected void declareTypes(
ValuationStatus status,
ValuationStartOperation startOperation,
ValuationOperation operation,
String id,
ValuationDocumentEntity entity,
ValuationContextData flowData,
ValuationDocumentDtoView mappedEntity) {
}
}
Antes de implementar a lógica de negócio, os processadores de anotações precisam gerar os ficheiros auxiliares:
./gradlew :customs:meumodulo:meumodulo-entities:compileJava
./gradlew :customs:meumodulo:meumodulo-spec:compileJava
ValuationDocumentDtoView,ValuationDocumentDtoCreate,ValuationDocumentDtoUpdateValuationDocumentFlowSupplier(interface para os Flows)ValuationDocumentValidationActivity(interface para validações)ValuationDocumentOperationcom constantes das operações
Crie uma classe Flow para cada operação documental:
package org.asycuda.cloud.customs.meumodulo.flows;
import jakarta.inject.Singleton;
import org.asycuda.cloud.customs.meumodulo.activities.validation.ValidateDeclarationDetails;
import org.asycuda.cloud.customs.meumodulo.activities.validation.ValidateAssessedValue;
import org.asycuda.cloud.customs.meumodulo.dtos.ValuationDocumentDtoView;
import org.asycuda.cloud.customs.meumodulo.entities.ValuationDocumentEntity;
import org.asycuda.cloud.customs.meumodulo.spec.*;
import org.asycuda.cloud.framework.extensions.document.FlowInstance;
import org.asycuda.cloud.framework.extensions.document.annotations.Flow;
import static org.asycuda.cloud.customs.meumodulo.spec.ValuationStatus.SUBMITTED;
import static org.asycuda.cloud.customs.meumodulo.spec.constants.ValuationDocumentOperation.SUBMIT;
public class FlowSubmit implements ValuationDocumentFlowSupplier {
@Flow(operation = SUBMIT)
@Singleton
public FlowInstance<
ValuationStatus,
ValuationStartOperation,
ValuationOperation,
String,
ValuationDocumentEntity,
ValuationContextData,
ValuationDocumentDtoView> produceFlow() {
return createFlow()
.activity(ValidateDeclarationDetails.class)
.activity(ValidateAssessedValue.class)
.status(SUBMITTED)
.end();
}
}
E a atividade de validação correspondente:
package org.asycuda.cloud.customs.meumodulo.activities.validation;
import org.asycuda.cloud.customs.meumodulo.dtos.ValuationDocumentDtoView;
import org.asycuda.cloud.customs.meumodulo.entities.ValuationDocumentEntity;
import org.asycuda.cloud.customs.meumodulo.spec.*;
import org.asycuda.cloud.customs.meumodulo.spec.activities.ValuationDocumentValidationActivity;
import org.asycuda.cloud.framework.extensions.core.exceptions.ASYCUDADomainException;
import org.asycuda.cloud.framework.extensions.document.context.DocumentContext;
import org.asycuda.cloud.framework.extensions.document.transactions.DocumentTransaction;
import static org.asycuda.cloud.framework.extensions.core.exceptions.models.ASYCUDADomainErrorDetail.errorPath;
public class ValidateAssessedValue implements ValuationDocumentValidationActivity {
@Override
public boolean validate(
DocumentTransaction<
ValuationStatus,
ValuationStartOperation,
ValuationOperation,
String,
ValuationDocumentEntity,
ValuationContextData,
ValuationDocumentDtoView> transaction) {
var entity = transaction.entity();
var ctx = transaction.ctx();
if (entity.getAssessedValue() == null || entity.getAssessedValue() <= 0) {
var error = errorPath("assessedValue").build();
ctx.addError(new ASYCUDADomainException("O valor avaliado deve ser superior a zero.", error));
}
return true;
}
}
Converta DTOs em Entidades respeitando as convenções de injeção CDI e coleções JPA:
package org.asycuda.cloud.customs.meumodulo.mappers;
import jakarta.persistence.EntityManager;
import org.asycuda.cloud.customs.meumodulo.dtos.*;
import org.asycuda.cloud.customs.meumodulo.entities.ValuationDocumentEntity;
import org.asycuda.cloud.framework.extensions.authentication.context.UserContext;
import org.mapstruct.*;
import org.mapstruct.MappingConstants.ComponentModel;
import org.mapstruct.control.DeepClone;
@Mapper(
componentModel = ComponentModel.JAKARTA_CDI,
builder = @Builder(disableBuilder = true),
mappingControl = DeepClone.class
)
public interface ValuationDocumentEntityMapper {
@Mapping(target = "id", ignore = true)
void map(
ValuationDocumentDtoCreate dto,
@MappingTarget ValuationDocumentEntity entity,
@Context EntityManager entityManager,
@Context UserContext ctx);
ValuationDocumentDtoView map(ValuationDocumentEntity entity);
ValuationDocumentEntity mapEntity(ValuationDocumentDtoView view);
}
Isole os dados automaticamente entre estâncias aduaneiras e despachantes (como feito no módulo de Inspeção):
package org.asycuda.cloud.customs.meumodulo.restrictions;
import org.asycuda.cloud.customs.meumodulo.dtos.ValuationDocumentDtoView;
import org.asycuda.cloud.customs.meumodulo.spec.*;
import org.asycuda.cloud.customs.meumodulo.spec.restrictions.ValuationDocumentClaimRestriction;
import org.asycuda.cloud.framework.extensions.document.annotations.DocumentRestriction;
import org.asycuda.cloud.framework.extensions.document.context.DocumentContext;
@DocumentRestriction(ValuationDocument.class)
public class RestrictionClaimOffice extends ValuationDocumentClaimRestriction {
@Override
public String name() { return "valuation-office-restriction"; }
@Override
public String claimName(DocumentContext<ValuationContextData> ctx) {
return "office"; // Claim da estância no JWT
}
@Override
public String searchPath(DocumentContext<ValuationContextData> ctx) {
return "data.customsOffice"; // Campo indexado no Elasticsearch / Search Engine
}
}
O ASY5 suporta 3 modalidades de comunicação entre módulos:
A. Endpoints REST JAX-RS / OpenAPI adicionais
@Path("/api/v1/valuation")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@ApplicationScoped
public class ValuationResource {
@Inject ValuationService valuationService;
@GET
@Path("/declaration/{ref}")
@RolesAllowed(ValuationSecurityDefinition.VIEWING)
public Response getAssessment(@PathParam("ref") String ref) {
return Response.ok(valuationService.findByReference(ref)).build();
}
}
B. Contratos gRPC de Alta Performance (Exemplo assignment-grpc)
Defina o ficheiro .proto sob grpc/src/main/proto/:
syntax = "proto3";
option java_multiple_files = true;
package org.asycuda.cloud.customs.meumodulo.grpc.api;
service ValuationServiceGrpc {
rpc evaluateRisk (ValuationRiskRequest) returns (ValuationRiskResponse) {}
}
message ValuationRiskRequest {
string declarationRef = 1;
double declaredValue = 2;
}
message ValuationRiskResponse {
bool requiresInspection = 1;
string riskLevel = 2;
}
C. Mensagens Assíncronas Kafka (Exemplo customs/cargo & declaration)
@ApplicationScoped
public class RiskKafkaConsumer {
@Incoming("risk")
public void processRiskEvent(RiskAssessmentEvent event) {
// Processa a resposta do canal de risco da alfândega
}
}
As traduções dos campos residem em ficheiros JSON localizados por classe:
{
"declarationReference": { "en": "Declaration Reference", "pt": "Referência da Declaração" },
"customsOffice": { "en": "Customs Office", "pt": "Estância Aduaneira" },
"assessedValue": { "en": "Assessed Value", "pt": "Valor Avaliado" }
}
E configure o ficheiro de runtime:
asycuda.module.name=valuation
quarkus.package.output-name=asy5-valuation
# Logging & SQL
quarkus.log.level=INFO
quarkus.hibernate-orm.log.format-sql=true
# Portas (Escolha portas livres, ex: 8099 para HTTP e 7189 para gRPC)
quarkus.http.port=8099
quarkus.http.root-path=/valuation
quarkus.grpc.server.port=7189
quarkus.grpc.server.enable-reflection-service=true
# Desabilitar integração automática de validação com Hibernate
quarkus.hibernate-orm.validation.enabled=false
# Validação do Token JWT SSO
mp.jwt.verify.audiences=sso
mp.jwt.verify.issuer=https://sso.asy5.local/realms/sso
mp.jwt.verify.publickey.location=https://sso.asy5.local/realms/sso/protocol/openid-connect/certs
mp.jwt.verify.requireiss=true
# Acordo de Licença UNCTAD Obrigatório
asycuda.core.license-agreement=My agency or/and I have obtained a proper license from UNCTAD/DTL/ASYCUDA Programme, and I carefully, with full understanding, read the license terms available at https://www.asycuda.org/licenses/LICENSE-1.0, and agree to it without any objection, and we shall be responsible for any legal obligations violating the license terms
Crie testes unitários e de integração utilizando JUnit 5 e Quarkus:
package org.asycuda.cloud.customs.meumodulo;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@QuarkusTest
public class ValuationServiceTest {
@Test
public void testValidationRules() {
// Validação das regras de negócio
assertTrue(true);
}
}
Comandos úteis de execução:
# 1. Validar se o Gradle reconhece todos os subprojetos
./gradlew projects | grep meumodulo
# 2. Compilar e executar testes
./gradlew :customs:meumodulo:build
# 3. Arrancar em modo de desenvolvimento contínuo (Live Reload)
./gradlew :customs:meumodulo:quarkusDev
05 Boas Práticas & Erros Frequentes ("Gotchas")
002-VALUATION-add-column.json) e inclua-o no master-changelog.json.
@OneToMany ou @ManyToMany geram erros graves de ciclo ou sobrescrita acidental se mapeadas automaticamente pelo MapStruct. Ignore-as na assinatura do mapper (@Mapping(target = "itens", ignore = true)) e faça o tratamento no método @AfterMapping persistindo os elementos filhos explicitamente com o EntityManager.
RestrictionClaimOperator, verifique sempre token.isServer() || isOperatorUnrestricted(token). Oficiais de alfândega e chamadas internas do sistema devem ter acesso irrestrito; a filtragem estrita por NIF aplica-se exclusivamente a utilizadores externos (despachantes e importadores).