Documento normativo. Audiencia: fábrica de desarrollo backend Nebula ERP.
Este es el núcleo del proyecto-patrón: describe, capa por capa y en el orden
real de construcción, cómo se levanta una feature de backend. Todo el código
citado es verbatim de dos features ya entregadas:
- Embargos —
nebula-treasury(esquematreasury), HT_TES_002.- Consecutivos —
nebula-masters(esquemaMASTERS), componente transversal.Se cita Embargos como caso completo y Consecutivos como contraste de las mismas
reglas en otro módulo. No se inventa código: los fragmentos están recortados a lo
ilustrativo con// ..., con la ruta del archivo real encima de cada bloque.
estado_embargo, estado_consecutivo); el campo heredado status de la base jamás representa negocio.SequenceBusinessBaseEntity (multitenant + auditoría).get-record y page-response leen de una vista Oracle aplanada @Immutable y devuelven un record inmutable. Nunca de la entidad de escritura.read / get / create / update usan el lightloader (Entity ↔ DTO). El lightloader NO produce records.nebula-shared, nunca por JOIN a tablas ajenas. El token va explícito por llamada (@RequestHeader Authorization).Orden recomendado: primero los contratos (models), luego los clientes
cross-module (shared), y dentro del servicio: Entity → View → Repository(s) →
Validator → Mapper → Lightloader → Component → Service → Controller.
Propósito. Contrato de escritura (create/update) y de transporte Entity↔DTO.
Lleva bean-validation y declara su propio id.
Regla canónica. Extiende BusinessBaseDto, declara private Long id; propio, y
el estado de negocio es un campo del dominio validado por patrón
(estadoEmbargo), nunca el status heredado.
nebula-models/src/main/java/com/centrica/nebula/models/treasury/dto/EmbargoDto.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class EmbargoDto extends BusinessBaseDto {
/** Id del embargo (null al crear; presente al consultar/mapear). */
private Long id;
// ...
/** Estado de negocio: ACTIVO / INACTIVO (al crear). LEVANTADO se asigna al levantar. */
@NotBlank
@Pattern(regexp = "^(ACTIVO|INACTIVO)$", message = "{treasury.embargo.estado.format}")
private String estadoEmbargo;
}
El Javadoc del DTO lo deja explícito: "Estado de negocio en
estadoEmbargo
(ACTIVO/INACTIVO); el campo internostatusde la base nunca se usa como negocio."
Propósito. Proyección inmutable y plana que devuelven get-record y
page-response. Es un record, no una clase mutable.
Regla canónica. Un record por vista de lectura. No incluye datos cross-module
(esos se enriquecen aparte por WebClient en responses de detalle/listado); el record
de la vista es entidad aplanada + derivados de la propia vista. Aplanado descriptivo
i18n (aditivo): además de los códigos/enums crudos (que se conservan), el record
expone campos *Nombre con la etiqueta legible de cada código resuelta por idioma
del token (ver 04 §3); el aplanado lo hace el Mapper,
nunca el front.
nebula-models/src/main/java/com/centrica/nebula/models/treasury/record/EmbargoViewResponse.java
/**
* Proyeccion plana del embargo desde la vista Oracle {@code treasury.v_embargos}
* (patron get-record / page-response del estandar ERP): entidad aplanada +
* saldo derivado, devuelta como record inmutable. No incluye nombres
* cross-modulo (deudor/acreedor/cuenta) ...
*/
public record EmbargoViewResponse(
Long id,
Long consecutivo,
String tipoEmbargo,
// ...
String estadoEmbargo,
String ordenJudicialLevantamiento,
LocalDate fechaLevantamiento,
BigDecimal valorEmbargado,
BigDecimal saldoEmbargo,
// Aplanado descriptivo i18n (aditivo): etiqueta legible del codigo/enum.
String tipoEmbargoNombre,
String ordenadoPorNombre,
String estadoEmbargoNombre) {
}
Contraste — mismo patrón en nebula-masters (los *Nombre resuelven los enums):
nebula-models/src/main/java/com/centrica/nebula/models/masters/consecutivo/record/ConsecutivoDefResponse.java
public record ConsecutivoDefResponse(
Long id,
String codigo,
String nombre,
// ...
EstadoConsecutivo estadoConsecutivo,
Long valorActual,
// Aplanado descriptivo i18n (aditivo) de los enums del consecutivo.
String modoNombre,
String scopeNombre,
String tipoValorNombre,
String tipoVigenciaNombre,
String estadoConsecutivoNombre
) {}
Propósito. Toda llamada a otro módulo (p. ej. tesorería → accounting para validar
deudor/acreedor/cuenta) pasa por un cliente declarativo @HttpExchange. No se hace
JOIN a tablas ajenas.
Reglas canónicas.
@HttpExchange con url base del recurso.@RequestHeader("Authorization") (NO se inyecta en config).SuccessResponse<T>; el contenido se lee con .getResponse().getContent().status.Mono).Variante bloqueante:
nebula-shared/src/main/java/com/centrica/nebula/shared/client/accounting/TerceroBlockingClient.java
@HttpExchange(url = "/nebula-accounting-core/api/v1/terceros", accept = "application/json",
contentType = "application/json")
public interface TerceroBlockingClient {
/** Valida que el tercero exista y este ACTIVO de negocio (RN-ALC2-05). */
@GetExchange("/validar-activo")
SuccessResponse<Boolean> validarActivo(@RequestParam Long id,
@RequestHeader("Authorization") String authorization);
/** Obtiene el DTO de un tercero por id (incluye {@code nombreCompleto} ya compuesto). */
@GetExchange("/get")
SuccessResponse<TerceroDto> get(@RequestParam Long id,
@RequestHeader("Authorization") String authorization);
/** Lista terceros activos de negocio (selector deudor/acreedor). */
@GetExchange("/activos")
SuccessResponse<List<TerceroResponse>> activos(@RequestHeader("Authorization") String authorization);
}
Variante reactiva (mismos contratos, retorno Mono):
nebula-shared/src/main/java/com/centrica/nebula/shared/client/accounting/TerceroReactiveClient.java
@HttpExchange(url = "/nebula-accounting-core/api/v1/terceros", accept = "application/json",
contentType = "application/json")
public interface TerceroReactiveClient {
@GetExchange("/validar-activo")
Mono<SuccessResponse<Boolean>> validarActivo(@RequestParam Long id,
@RequestHeader("Authorization") String authorization);
@GetExchange("/get")
Mono<SuccessResponse<TerceroDto>> get(@RequestParam Long id,
@RequestHeader("Authorization") String authorization);
@GetExchange("/activos")
Mono<SuccessResponse<List<TerceroResponse>>> activos(@RequestHeader("Authorization") String authorization);
}
Autoconfig. Los proxies se registran vía AutoConfiguration.imports; el consumidor
solo inyecta el bean, no lo define. Activación opt-in nebula.clients.enabled=true,
con opt-out individual por estilo. El token de auth NO se configura aquí.
nebula-shared/src/main/java/com/centrica/nebula/shared/client/config/NebulaClientsAutoConfiguration.java
@AutoConfiguration
@ConditionalOnProperty(prefix = "nebula.clients", name = "enabled", havingValue = "true")
@Slf4j
public class NebulaClientsAutoConfiguration {
private static final String ENV_HEADER = "x-simappe-environment";
@Value("${nebula.clients.base-url}")
private String baseUrl;
// ...
/** Crea un proxy @HttpExchange bloqueante (RestClient) sobre el gateway. */
private <T> T blockingProxy(Class<T> clientType) {
RestClient restClient = RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(ENV_HEADER, environment)
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(clientType);
}
// ...
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "nebula.clients.blocking", name = "enabled",
havingValue = "true", matchIfMissing = true)
public TerceroBlockingClient terceroBlockingClient() {
return blockingProxy(TerceroBlockingClient.class);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "nebula.clients.reactive", name = "enabled",
havingValue = "true", matchIfMissing = true)
public TerceroReactiveClient terceroReactiveClient() {
return reactiveProxy(TerceroReactiveClient.class);
}
}
Propósito. Tabla de escritura del dominio.
Regla canónica. Extiende SequenceBusinessBaseEntity (id por secuencia +
multitenant + auditoría). Los maestros de otros módulos se referencian por FK
lógica (sin FK física). El estado de negocio es un @Column propio
(estado_embargo); status heredado es interno.
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/entity/EmbargoEntity.java
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
@Table(name = "embargos", schema = "treasury")
public class EmbargoEntity extends SequenceBusinessBaseEntity {
private static final long serialVersionUID = 2026060500000002L;
// ...
/** FK logica -> accounting.terceros.id (deudor). Activo (RN-ALC2-05). */
@Column(name = "deudor_tercero_id", nullable = false)
private Long deudorTerceroId;
// ...
/** Estado de NEGOCIO: ACTIVO / INACTIVO / LEVANTADO. */
@Column(name = "estado_embargo", nullable = false, length = 12)
private String estadoEmbargo;
}
@Immutable (lectura)Propósito. Vista Oracle aplanada que alimenta get-record y page-response.
Mueve los derivados (p. ej. saldo_embargo) a la base de datos y entrega una
proyección lista para serializar.
Regla canónica. @Entity @Immutable sobre la vista (v_embargos). Extiende
SequenceBusinessBaseEntity para heredar las columnas de tenant/auditoría; el
aislamiento por tenant en la lectura es EXPLÍCITO (findByIdAndTenant nativo para
get-record; el page-response/page se resuelven con NativeTenantPaginator, que
inyecta el tenant del token en SQL nativo — §2.10), no por filtro automático ni por la
conexión. Solo getters/setters de proyección; nada de lógica.
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/entity/EmbargoView.java
@Getter
@Setter
@Entity
@Immutable
@Table(name = "v_embargos", schema = "treasury")
public class EmbargoView extends SequenceBusinessBaseEntity {
private static final long serialVersionUID = 2026060500000003L;
// ...
/** Derivado en la vista: Pension=0; otros = valor_embargo (RN-ALC1-03). */
@Column(name = "saldo_embargo")
private BigDecimal saldoEmbargo;
}
Contraste — la vista de consecutivos sigue idéntico patrón:
nebula-masters/src/main/java/com/centrica/nebula/masters/v1/consecutivo/entity/ConsecutivoDefView.java
@Getter
@Setter
@Entity
@Immutable
@Table(name = "V_CONSECUTIVO_DEF", schema = "MASTERS")
public class ConsecutivoDefView extends SequenceBusinessBaseEntity {
// ...
/** Estado de negocio del consecutivo (VIGENTE/SUSPENDIDO/EXPIRADO/AGOTADO); no es el status interno. */
@Enumerated(EnumType.STRING)
@Column(name = "estado_consecutivo")
private EstadoConsecutivo estadoConsecutivo;
}
Propósito. Acceso a datos. Uno para la entidad (escritura + queries de dominio),
uno para la vista (solo lectura).
Regla canónica. Ambos extienden SimappeRepository<T, Long> (paginación y specs).
El aislamiento multitenant NO es automático: el @Filter de Hibernate no se aplica,
por lo que todo método LOCAL filtra el tenant EXPLÍCITAMENTE con consulta nativa
(@Query(nativeQuery=true) con client_id+company_id+subsidiary_id del token —
ver 09_REGLAS_EXPLICITAS §2). Los métodos heredados de
lectura (findById/findAll) no se usan en negocio (fugan cross-tenant); se
reemplazan por su variante ...AndTenant. La paginación tampoco se delega al default
de commons (PageResponseSupport sobre findAll(Specification, Pageable) no aísla
tenant — la vista extiende SequenceBusinessBaseEntity, que no es TenantAware → fuga,
ver AP-1.5): se resuelve con NativeTenantPaginator
(§2.10). Código real en develop:
nebula-treasury/.../embargo/repository/EmbargoRepository.java
@Repository
public interface EmbargoRepository extends SimappeRepository<EmbargoEntity, Long> {
/**
* Busca un embargo por id, aislado por tenant.
*
* @param id identificador del embargo
* @param clientId cliente (tenant) del token
* @param companyId compañía (tenant) del token
* @param subsidiaryId sucursal (tenant) del token; puede ser null
* @return el embargo del tenant si existe, vacío en caso contrario
*/
@Query(value = "SELECT * FROM treasury.embargos WHERE id = :id "
+ "AND client_id = :clientId AND company_id = :companyId "
+ "AND NVL(subsidiary_id, -1) = NVL(:subsidiaryId, -1)", nativeQuery = true)
Optional<EmbargoEntity> findByIdAndTenant(@Param("id") Long id, @Param("clientId") Long clientId,
@Param("companyId") Long companyId, @Param("subsidiaryId") Long subsidiaryId);
@Query(value = "SELECT * FROM treasury.embargos WHERE client_id = :clientId AND company_id = :companyId "
+ "AND NVL(subsidiary_id, -1) = NVL(:subsidiaryId, -1)", nativeQuery = true)
List<EmbargoEntity> findAllByTenant(@Param("clientId") Long clientId, @Param("companyId") Long companyId,
@Param("subsidiaryId") Long subsidiaryId);
/** Unicidad orden/deudor por tenant (RN-ALC2-01). Reemplaza el existsBy derivado. */
@Query(value = "SELECT COUNT(*) FROM treasury.embargos WHERE deudor_tercero_id = :deudorTerceroId "
+ "AND orden_judicial = :ordenJudicial AND client_id = :clientId AND company_id = :companyId "
+ "AND NVL(subsidiary_id, -1) = NVL(:subsidiaryId, -1)", nativeQuery = true)
long countByDeudorTerceroIdAndOrdenJudicialAndTenant(@Param("deudorTerceroId") Long deudorTerceroId,
@Param("ordenJudicial") String ordenJudicial, @Param("clientId") Long clientId,
@Param("companyId") Long companyId, @Param("subsidiaryId") Long subsidiaryId);
// ALC1 (listado): NO se expone una query propia sin cota. El listado paginado se
// resuelve por NativeTenantPaginator sobre la vista v_embargos, ordenando por la
// columna prioridad_tipo (Pension=1, Fiscales=2, Civiles=3) -> fecha_inicio DESC
// (ver el component POST /listado y la migracion de v_embargos).
}
nebula-treasury/.../embargo/repository/EmbargoViewRepository.java — el ViewRepository
ya no queda vacío: expone findByIdAndTenant nativo sobre la vista (para get-record).
@Repository
public interface EmbargoViewRepository extends SimappeRepository<EmbargoView, Long> {
@Query(value = "SELECT * FROM treasury.v_embargos WHERE id = :id "
+ "AND client_id = :clientId AND company_id = :companyId "
+ "AND NVL(subsidiary_id, -1) = NVL(:subsidiaryId, -1)", nativeQuery = true)
Optional<EmbargoView> findByIdAndTenant(@Param("id") Long id, @Param("clientId") Long clientId,
@Param("companyId") Long companyId, @Param("subsidiaryId") Long subsidiaryId);
}
El Component extrae el tenant del token (SimappeJwt → getCustomerId/getCompanyId/getSubsidiaryId) y lo pasa a cada query; los LightLoaders (sin request) lo obtienen del hilo con RequestContextHolder + SimappeJwt.
Javadoc (obligatorio, 09 §8). En
developcada método —también los nativos con@Query— lleva su Javadoc literal con@param/@returnencima del@Query, como se muestra arriba enfindByIdAndTenant. En el resto del ejemplo se omite por brevedad de lectura, pero en el código es 100% y se valida en el cierre de alcance (contar bloques de doc = métodos). Aplica igual a entity (campos), component (métodos + helpertenant()), mapper, lightloader y controller.
Propósito. Reglas de negocio puras (sin cross-module). Las cross-module las
orquesta el component vía WebClient.
Regla canónica. @Component dedicado; cada violación lanza SimappeException con
HTTP status y mensaje i18n (resuelto por el *Messages del módulo, ES+EN siempre).
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/validator/EmbargoValidator.java
@Component
@RequiredArgsConstructor
public class EmbargoValidator {
private final TreasuryMessages messages;
/** RN-ALC2-02/03/04. Lanza 400 con el mensaje correspondiente. */
public void validarCondicionales(EmbargoDto dto) {
boolean esPension = TreasuryDomainConstants.TIPO_PENSION_ALIMENTICIA.equals(dto.getTipoEmbargo());
if (esPension) {
// RN-ALC2-02: mayoria de edad obligatoria y > hoy.
if (dto.getMayoriaEdad() == null || !dto.getMayoriaEdad().isAfter(LocalDate.now())) {
throw new SimappeException(messages.get(TreasuryDomainConstants.MSG_ERR_MAYORIA_EDAD),
HttpStatus.BAD_REQUEST);
}
}
// ...
}
// ...
}
Propósito. Convertir vista → record (toViewResponse) y entidad → record
(toResponse), aplanando códigos/enums a etiquetas i18n e inyectando los datos
cross-module ya resueltos por el component.
Reglas canónicas.
NebulaEnrichedMapper<Entity, Response> (de nebula-models),R toResponse(E entity, Map<String, Map<Long, Object>> context): elcontext transporta enriquecimientos resueltos aparte por el component (p. ej. elvalorActual del consecutivo, o nombres cross-module), agrupados por clave lógica.toViewResponse(View) como conversor canónico de get-record /page-response (el NativeTenantPaginator recibe mapper::toViewResponse).*Nombre resuelto conmessages.getOrDefault("<PREFIX>.<VALOR>", <default>) — si la clave no existe, cae alname() crudo (nunca rompe). El mapper no llama WebClients ni accede a repos.nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/mapper/EmbargoMapper.java
public class EmbargoMapper implements NebulaEnrichedMapper<EmbargoEntity, EmbargoViewResponse> {
private static final String KEY_TIPO = "TREASURY.EMBARGO.TIPO.";
private static final String KEY_ORDENADO_POR = "TREASURY.EMBARGO.ORDENADO_POR.";
private static final String KEY_ESTADO = "TREASURY.EMBARGO.ESTADO.";
private final TreasuryMessages messages;
// ...
/** Vista plana ({@code v_embargos}) -> record (get-record / page-response). */
public EmbargoViewResponse toViewResponse(EmbargoView v) {
return new EmbargoViewResponse(
v.getId(), v.getConsecutivo(), v.getTipoEmbargo(), /* ... */ v.getSaldoEmbargo(),
// aplanado descriptivo i18n (aditivo)
nombre(KEY_TIPO, v.getTipoEmbargo()), nombre(KEY_ORDENADO_POR, v.getOrdenadoPor()),
nombre(KEY_ESTADO, v.getEstadoEmbargo()));
}
/** Contrato {@link NebulaEnrichedMapper}: ENTIDAD -> record (mismo aplanado i18n). */
@Override
public EmbargoViewResponse toResponse(EmbargoEntity e, Map<String, Map<Long, Object>> context) {
return new EmbargoViewResponse(
e.getId(), e.getConsecutivo(), e.getTipoEmbargo(), /* ... */ /* saldo */ null,
nombre(KEY_TIPO, e.getTipoEmbargo()), nombre(KEY_ORDENADO_POR, e.getOrdenadoPor()),
nombre(KEY_ESTADO, e.getEstadoEmbargo()));
}
/** Etiqueta i18n del codigo; si no hay clave, devuelve el codigo crudo. */
private String nombre(String prefix, String codigo) {
return (codigo == null || codigo.isBlank()) ? null : messages.getOrDefault(prefix + codigo, codigo);
}
}
Contraste — ConsecutivoDefMapper sigue idéntico contrato (enums vía name()):
nebula-masters/src/main/java/com/centrica/nebula/masters/v1/consecutivo/Mapper/ConsecutivoDefMapper.java
public class ConsecutivoDefMapper implements NebulaEnrichedMapper<ConsecutivoDefEntity, ConsecutivoDefResponse> {
private static final String KEY_ESTADO = "CONSECUTIVO.ESTADO."; // + MODO / SCOPE / TIPO_VALOR / TIPO_VIGENCIA
// ...
@Override
public ConsecutivoDefResponse toResponse(ConsecutivoDefEntity e, Map<String, Map<Long, Object>> context) {
var valoresMap = context.getOrDefault("VALOR_ACTUAL", Map.of()); // enriquecimiento por context
// ... nombre(KEY_MODO, e.getModo()), nombre(KEY_ESTADO, e.getEstadoConsecutivo()), ...
}
/** Para enums: la clave usa el name() del enum. */
private String nombre(String prefix, Enum<?> value) {
return value == null ? null : messages.getOrDefault(prefix + value.name(), value.name());
}
}
Propósito. Mapeo Entity↔DTO (patrón anti-N+1 del ERP) para read/get/create/update.
Solo para DTOs, nunca para records.
Regla canónica. Extiende AbstractLoadModels<Id, Dto, Entity>, anotado
@ConnectionContext @EntityIdSupport. Usa SimappeModelMapper con fallback manual.
Si no hay relaciones locales que cargar, loadForDto / loadForListDto quedan no-op
(los maestros de accounting se consumen por WebClient en el component, no aquí).
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/lightloader/EmbargoLightloader.java
@ConnectionContext
@EntityIdSupport
@AllArgsConstructor
@Slf4j
@Component
public class EmbargoLightloader extends AbstractLoadModels<Long, EmbargoDto, EmbargoEntity> {
private final EmbargoRepository embargoRepository;
private final SimappeModelMapper modelMapper;
@PostConstruct
public void init() {
modelMapper.configureEntityIdConverters();
log.info("EmbargoLightloader inicializado.");
}
@Override
public EmbargoDto loadForEntity(EmbargoEntity entity) {
if (entity == null) {
return null;
}
try {
return modelMapper.map(entity, EmbargoDto.class);
} catch (Exception ex) {
log.warn("ModelMapper fallo para Embargo {}, mapeo manual: {}", entity.getId(), ex.getMessage());
return createManualMapping(entity);
}
}
// ...
}
Propósito. Corazón de la feature: implementa los contratos estándar del ERP y agrega
las operaciones de dominio.
Reglas canónicas.
extends SimappeService implements CrudHtppServletRequestComponent<Dto, Long>, PageResponseSupport<Record, Long>.@ConnectionContext @EntityIdSupport.getRecord lee de la vista (ViewRepository) y mapea a record con mapper::toViewResponse.getPageResponseQuery / getPageDtoQuery se resuelven con NativeTenantPaginator (getPageResponseNative / getPageDtoNative), que inyecta SIEMPRE el tenant del token en SQL nativo. No se usa el default de la interfaz sobre findAll(Specification, Pageable) (no aísla tenant — AP-1.5). Un helper aplicarOrden…PorDefecto fija un orden estable cuando el cliente no envía ordersBy.read/get/create/update usan el lightloader (Entity↔DTO).Firma de clase:
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/component/EmbargoComponent.java
@ConnectionContext
@EntityIdSupport
@AllArgsConstructor
@Slf4j
@Component
public class EmbargoComponent extends SimappeService
implements CrudHtppServletRequestComponent<EmbargoDto, Long>,
PageResponseSupport<EmbargoViewResponse, Long> {
private final EmbargoRepository embargoRepository;
private final EmbargoViewRepository embargoViewRepository;
private final EmbargoLightloader lightloader;
private final EmbargoValidator validator;
private final EmbargoMapper mapper;
private final TreasuryMessages messages;
private final NativeTenantPaginator nativeTenantPaginator; // paginacion nativa con tenant
private final ConsecutivoBlockingClient consecutivoClient;
private final TerceroBlockingClient terceroClient;
private final PlanCuentaContableBlockingClient cuentaClient;
private final AuditLogService auditLogService;
// ...
}
getRecord (lee de la VISTA) y la paginación (vía NativeTenantPaginator, tenant explícito):
@Override
@Transactional(readOnly = true)
public EmbargoViewResponse getRecord(Long id, HttpServletRequest request) throws SimappeException {
UserSession us = tenant(request); // tenant del token (SimappeJwt)
EmbargoView v = embargoViewRepository.findByIdAndTenant(
id, us.getCustomerId(), us.getCompanyId(), us.getSubsidiaryId()).orElseThrow(
() -> new SimappeException(messages.get(TreasuryDomainConstants.MSG_ERR_NOTFOUND, String.valueOf(id)),
HttpStatus.NOT_FOUND));
audit("getRecord", SimappeConstant.RECORD_RETURNED_SUCCESSFULLY, EventType.GET, request, id);
return mapper.toViewResponse(v);
}
@Override
@Transactional(readOnly = true)
public PageResponse<EmbargoViewResponse> getPageResponseQuery(SimappeRequestQuery query, HttpServletRequest request)
throws SimappeException {
audit("getPageResponseQuery", SimappeConstant.PAGE_RETURNED_CORRECTLY, EventType.READ_PAGE, request, query);
aplicarOrdenPorDefecto(query);
return nativeTenantPaginator.getPageResponseNative(query, tenant(request),
EmbargoView.class, mapper::toViewResponse);
}
// page-dto: misma fuente (vista), salida DTO. Tambien por NativeTenantPaginator.
@Transactional(readOnly = true)
public PageDto<EmbargoDto> getPageDtoQuery(SimappeRequestQuery requestQuery, HttpServletRequest request)
throws SimappeException {
audit("getPageDtoQuery", SimappeConstant.PAGE_RETURNED_CORRECTLY, EventType.READ_PAGE, request, requestQuery);
aplicarOrdenPorDefecto(requestQuery);
return nativeTenantPaginator.getPageDtoNative(requestQuery, tenant(request),
EmbargoView.class, v -> returnDto(v, EmbargoDto.class));
}
/** Orden estable por defecto (consecutivo ASC) cuando el cliente no envia ordersBy. */
private void aplicarOrdenPorDefecto(SimappeRequestQuery query) {
if (query.getOrdersBy() == null || query.getOrdersBy().isEmpty()) {
query.setOrdersBy(List.of(new SortCriteria("consecutivo", SortOperation.ASC)));
}
}
create muestra el patrón completo de orquestación: validación pura → validaciones
cross-module por WebClient (token explícito, estado de negocio) → unicidad →
emisión de consecutivo transversal → persistencia → auditoría:
@Override
@Transactional
public EmbargoDto create(EmbargoDto dto, HttpServletRequest request) throws SimappeException {
String auth = auth(request);
// ...
validator.validarCondicionales(dto); // RN-ALC2-02/03/04
validarTerceroActivo(dto.getDeudorTerceroId(), auth); // RN-ALC2-05 (WebClient)
validarTerceroActivo(dto.getAcreedorTerceroId(), auth); // RN-ALC2-05 (WebClient)
validarCuentaMovimiento(dto.getCuentaContableId(), auth); // RN-ALC2-06 (WebClient)
// ... unicidad RN-ALC2-01
long consecutivo = emitirConsecutivo(dto.getDeudorTerceroId(), dto.getOrdenJudicial(), auth);
// ... e.setEstadoEmbargo(dto.getEstadoEmbargo()); save(...)
audit("create", SimappeConstant.SUCCESSFULLY_CREATED_RECORD, EventType.CREATE, request, dto);
return lightloader.loadForEntity(e);
}
La validación cross-module va por el estado de NEGOCIO, con token explícito:
private void validarTerceroActivo(Long terceroId, String auth) {
Boolean activo = terceroClient.validarActivo(terceroId, auth).getResponse().getContent();
if (!Boolean.TRUE.equals(activo)) {
throw new SimappeException(messages.get(TreasuryDomainConstants.MSG_ERR_TERCERO), HttpStatus.CONFLICT);
}
}
private String auth(HttpServletRequest request) {
return request.getHeader("Authorization");
}
Auditoría estándar (idéntica forma en todos los components):
private void audit(String op, String msg, EventType tipo, HttpServletRequest request, Object detalle) {
auditLogService.sendMessage(SimappeUtilities.createAuditLogMessage(op, msg, tipo,
this.getClass().getCanonicalName(), request.getHeader(SimappeConstant.AUTHORIZATION), detalle));
}
Contraste con Consecutivos. Misma firma de clase y misma paginación vía
NativeTenantPaginator (getPageResponseNative / getPageDtoNative con
aplicarOrdenCodigoPorDefecto); la diferencia es que getRecord lee la vista de
masters y los detalles son locales (sin cross-module). Mismo patrón en
ConsecutivoTipoComponent (catálogo de tipos):
nebula-masters/src/main/java/com/centrica/nebula/masters/v1/consecutivo/component/ConsecutivoDefComponent.java
public class ConsecutivoDefComponent extends SimappeService
implements CrudHtppServletRequestComponent<ConsecutivoDefDto, Long>,
PageResponseSupport<ConsecutivoDefResponse, Long> {
// ...
@Override
@Transactional(readOnly = true)
public ConsecutivoDefResponse getRecord(Long id, HttpServletRequest request) throws SimappeException {
UserSession us = tenant(request); // tenant del token (SimappeJwt)
ConsecutivoDefView v = defViewRepository.findByIdAndTenant(
id, us.getCustomerId(), us.getCompanyId(), us.getSubsidiaryId()).orElseThrow(
() -> new SimappeException(messages.get(MastersDomainConstants.MSG_ERROR_CONSECUTIVO_NOTFOUND, id),
HttpStatus.NOT_FOUND));
auditar("getRecord", SimappeConstant.RECORD_RETURNED_SUCCESSFULLY, EventType.GET, request, id);
return mapper.toViewResponse(v);
}
@Override
@Transactional(readOnly = true)
public PageResponse<ConsecutivoDefResponse> getPageResponseQuery(SimappeRequestQuery query, HttpServletRequest request)
throws SimappeException {
auditar("getPageResponseQuery", SimappeConstant.PAGE_RETURNED_CORRECTLY, EventType.READ_PAGE, request, query);
aplicarOrdenCodigoPorDefecto(query);
return nativeTenantPaginator.getPageResponseNative(query, tenant(request),
ConsecutivoDefView.class, mapper::toViewResponse);
}
}
Service. Fachada fina @Service que delega en el component. No tiene lógica.
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/service/EmbargoService.java
@AllArgsConstructor
@Service
public class EmbargoService {
private final EmbargoComponent component;
public EmbargoViewResponse getRecord(Long id, HttpServletRequest request) throws SimappeException {
return component.getRecord(id, request);
}
public PageResponse<EmbargoViewResponse> getPageResponseQuery(SimappeRequestQuery query, HttpServletRequest request)
throws SimappeException {
return component.getPageResponseQuery(query, request);
}
// ... index / read / get / create / actualizar / levantar / delete / buscarTerceros / buscarCuentas
}
Controller. extends SimappeController, base /api/v1/<recurso>. Expone CRUD +
operaciones de dominio + el estándar ERP /get-record (GET) y /page-response (POST).
nebula-treasury/src/main/java/com/centrica/nebula/finance/treasury/v1/embargo/controller/EmbargoController.java
@AllArgsConstructor
@RestController
@RequestMapping(path = "/api/v1/embargos", produces = MediaType.APPLICATION_JSON_VALUE)
public class EmbargoController extends SimappeController {
private static final long serialVersionUID = 1L;
private final EmbargoService embargoService;
@GetMapping(path = "/index") // SISTEMA: probe de disponibilidad (ServiceResponseAvailable)
public ResponseEntity<ServiceResponseAvailable> index(HttpServletRequest request) { /* ... */ }
@PostMapping(path = "/listado") // ALC1: listado paginado por estado (NativeTenantPaginator sobre v_embargos)
public ResponseEntity<PageResponse<EmbargoListadoResponse>> listado(
@RequestBody SimappeRequestQuery query, HttpServletRequest request) { /* ... */ }
@GetMapping(path = "/read") // ALC6: detalle (cross-module enriquecido)
public ResponseEntity<EmbargoDetalleResponse> read(@RequestParam Long id, HttpServletRequest request) { /* ... */ }
@PostMapping(path = "/create") // create
public ResponseEntity<EmbargoDto> create(@Valid @RequestBody EmbargoDto dto, HttpServletRequest request)
throws SimappeException { /* ... */ }
@PatchMapping(path = "/update") // ALC3: edicion restringida
public ResponseEntity<EmbargoDetalleResponse> update(@Valid @RequestBody EmbargoUpdateDto dto,
HttpServletRequest request) { /* ... */ }
@PostMapping(path = "/levantar") // ALC4: dominio
public ResponseEntity<EmbargoDetalleResponse> levantar(@Valid @RequestBody LevantamientoDto dto,
HttpServletRequest request) { /* ... */ }
@DeleteMapping(path = "/delete") // ALC5: eliminacion fisica (solo INACTIVO)
public ResponseEntity<Void> delete(@RequestParam Long id, HttpServletRequest request) throws SimappeException { /* ... */ }
@GetMapping(path = "/get") // CRUD estandar: DTO por id
public ResponseEntity<EmbargoDto> get(@RequestParam Long id, HttpServletRequest request) throws SimappeException { /* ... */ }
@GetMapping(path = "/get-record") // Estandar ERP: record plano desde la vista
public ResponseEntity<EmbargoViewResponse> getRecord(@RequestParam Long id, HttpServletRequest request)
throws SimappeException { /* ... */ }
@PostMapping(path = "/page-response") // Estandar ERP: paginacion de records sobre la vista
public ResponseEntity<PageResponse<EmbargoViewResponse>> getPageResponseQuery(
@RequestBody SimappeRequestQuery query, HttpServletRequest request) throws SimappeException { /* ... */ }
}
POST /api/v1/embargos/create — registro de embargo con validaciones cross-module y
emisión de consecutivo:
@Immutable (record) vs Lightloader (DTO): cuándo usar cada uno| Aspecto | Vista @Immutable → record |
Lightloader → DTO |
|---|---|---|
| Endpoints | /get-record, /page-response |
read, get, create, update |
| Fuente de datos | Vista Oracle aplanada (v_embargos) |
Tabla de entidad (embargos) |
| Tipo de salida | record inmutable (EmbargoViewResponse) |
DTO mutable (EmbargoDto) |
| Mecanismo | mapper.toViewResponse(View) |
AbstractLoadModels.loadForEntity(Entity) |
| Derivados (saldo, etc.) | Calculados en la vista (BD) | No aplica |
| Mutabilidad | Solo lectura (@Immutable) |
Escritura/transporte |
| Validación | No (proyección) | Bean-validation en el DTO |
Regla mnemotécnica: si el cliente va a editar, es DTO (lightloader); si solo va a
listar o mostrar plano, es record (vista). Nunca devolver la entidad de escritura por la
API ni armar records a mano fuera del mapper.