ted structural search
parent
0ce5f51382
commit
b3fe628a02
@ -0,0 +1,175 @@
|
|||||||
|
# Wave 2 — NEW TED Structured Search
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Wave 2 adds a NEW-runtime TED search endpoint that keeps the legacy request and response shape of `/v1/documents/search`, but executes the search against `TED.ted_notice_projection` instead of the legacy search path.
|
||||||
|
|
||||||
|
The goal is twofold:
|
||||||
|
|
||||||
|
1. provide NEW-runtime structured TED search functionality
|
||||||
|
2. make cutover measurable through parity checks against the legacy search implementation
|
||||||
|
|
||||||
|
## Runtime scope
|
||||||
|
|
||||||
|
This functionality is active only in `RuntimeMode.NEW`.
|
||||||
|
|
||||||
|
Controller:
|
||||||
|
- `at.procon.dip.domain.ted.web.TedStructuredSearchController`
|
||||||
|
|
||||||
|
Service:
|
||||||
|
- `at.procon.dip.domain.ted.service.TedStructuredSearchService`
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
- `at.procon.dip.domain.ted.search.TedStructuredSearchRepository`
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
### GET
|
||||||
|
`GET /v1/documents/search`
|
||||||
|
|
||||||
|
### POST
|
||||||
|
`POST /v1/documents/search`
|
||||||
|
|
||||||
|
The POST body uses the existing legacy-compatible DTO:
|
||||||
|
- `at.procon.ted.model.dto.DocumentDtos.SearchRequest`
|
||||||
|
|
||||||
|
The response uses:
|
||||||
|
- `at.procon.ted.model.dto.DocumentDtos.SearchResponse`
|
||||||
|
|
||||||
|
## Implemented structured filters
|
||||||
|
|
||||||
|
The Wave 2 implementation supports these filters:
|
||||||
|
|
||||||
|
- `countryCode`
|
||||||
|
- `countryCodes`
|
||||||
|
- `noticeType`
|
||||||
|
- `contractNature`
|
||||||
|
- `procedureType`
|
||||||
|
- `cpvPrefix`
|
||||||
|
- `cpvCodes`
|
||||||
|
- `nutsCode`
|
||||||
|
- `nutsCodes`
|
||||||
|
- `publicationDateFrom`
|
||||||
|
- `publicationDateTo`
|
||||||
|
- `submissionDeadlineAfter`
|
||||||
|
- `euFunded`
|
||||||
|
- `buyerNameContains`
|
||||||
|
- `projectTitleContains`
|
||||||
|
|
||||||
|
## Sorting and pagination
|
||||||
|
|
||||||
|
Supported sorting:
|
||||||
|
|
||||||
|
- `publicationDate`
|
||||||
|
- `submissionDeadline`
|
||||||
|
- `buyerName`
|
||||||
|
- `projectTitle`
|
||||||
|
|
||||||
|
Supported directions:
|
||||||
|
|
||||||
|
- `asc`
|
||||||
|
- `desc`
|
||||||
|
|
||||||
|
Pagination behavior:
|
||||||
|
|
||||||
|
- page defaults to `0`
|
||||||
|
- size defaults to `DipSearchProperties.defaultPageSize`
|
||||||
|
- size is capped by `DipSearchProperties.maxPageSize`
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
The endpoint reads from:
|
||||||
|
- `TED.ted_notice_projection`
|
||||||
|
|
||||||
|
This means the quality and completeness of the search results depend on Wave 1 migration and projection backfill completeness.
|
||||||
|
|
||||||
|
## Functional behavior
|
||||||
|
|
||||||
|
The Wave 2 implementation is intentionally **structured-search-first**.
|
||||||
|
|
||||||
|
Although the request DTO still contains:
|
||||||
|
- `semanticQuery`
|
||||||
|
- `similarityThreshold`
|
||||||
|
|
||||||
|
these fields are currently accepted only for request compatibility and future extension. The current repository implementation does **not** apply semantic ranking or semantic filtering.
|
||||||
|
|
||||||
|
That is deliberate for Wave 2, because the main objective is:
|
||||||
|
- structured search on the NEW model
|
||||||
|
- parity verification against legacy behavior for common structured filters
|
||||||
|
|
||||||
|
## Parity strategy
|
||||||
|
|
||||||
|
Wave 2 adds parity-focused tests that compare NEW structured search behavior against the legacy TED search for a common subset of structured filters.
|
||||||
|
|
||||||
|
Recommended parity focus:
|
||||||
|
|
||||||
|
- country filters
|
||||||
|
- notice type
|
||||||
|
- procedure type
|
||||||
|
- publication date range
|
||||||
|
- EU-funded filter
|
||||||
|
- deterministic sort order
|
||||||
|
|
||||||
|
Parity should be evaluated on:
|
||||||
|
|
||||||
|
- total result count
|
||||||
|
- ordered publication ids / notice ids for stable cases
|
||||||
|
- key metadata fields in `DocumentSummary`
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
1. No semantic scoring is applied in the NEW structured TED search path yet.
|
||||||
|
2. No TED facets/aggregations are included yet.
|
||||||
|
3. Search is projection-based, so missing or stale `ted_notice_projection` rows can cause parity differences.
|
||||||
|
4. The Wave 2 scope is TED-specific structured retrieval, not the full generic hybrid search fusion pipeline.
|
||||||
|
|
||||||
|
## Example GET request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v1/documents/search?countryCode=AT¬iceType=CN_STANDARD&publicationDateFrom=2025-01-01&publicationDateTo=2025-12-31&page=0&size=20&sortBy=publicationDate&sortDirection=desc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example POST request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"countryCodes": ["AT", "DE"],
|
||||||
|
"noticeType": "CN_STANDARD",
|
||||||
|
"contractNature": "SERVICES",
|
||||||
|
"procedureType": "OPEN",
|
||||||
|
"cpvPrefix": "79000000",
|
||||||
|
"cpvCodes": ["79341000"],
|
||||||
|
"nutsCodes": ["AT130", "DE300"],
|
||||||
|
"publicationDateFrom": "2025-01-01",
|
||||||
|
"publicationDateTo": "2025-12-31",
|
||||||
|
"submissionDeadlineAfter": "2025-06-01T00:00:00Z",
|
||||||
|
"euFunded": true,
|
||||||
|
"buyerNameContains": "city",
|
||||||
|
"projectTitleContains": "digital",
|
||||||
|
"semanticQuery": "framework agreement for digital transformation services",
|
||||||
|
"similarityThreshold": 0.7,
|
||||||
|
"page": 0,
|
||||||
|
"size": 20,
|
||||||
|
"sortBy": "publicationDate",
|
||||||
|
"sortDirection": "desc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Postman collection
|
||||||
|
|
||||||
|
Use the companion file:
|
||||||
|
- `WAVE2_TED_STRUCTURED_SEARCH.postman_collection.json`
|
||||||
|
|
||||||
|
It contains:
|
||||||
|
- basic GET search
|
||||||
|
- CPV/NUTS/buyer GET example
|
||||||
|
- full POST structured request
|
||||||
|
- a parity-oriented GET request for manual comparison against legacy search
|
||||||
|
|
||||||
|
## Recommended next step after Wave 2 validation
|
||||||
|
|
||||||
|
After parity is accepted, the next logical enhancement is:
|
||||||
|
|
||||||
|
1. add TED facets and richer structural filters
|
||||||
|
2. merge structured TED narrowing with lexical/semantic ranking
|
||||||
|
3. expose a documented parity validation checklist for cutover approval
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
package at.procon.dip.domain.ted.search;
|
||||||
|
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.DocumentSummary;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.SearchRequest;
|
||||||
|
import at.procon.ted.model.entity.ContractNature;
|
||||||
|
import at.procon.ted.model.entity.NoticeType;
|
||||||
|
import at.procon.ted.model.entity.ProcedureType;
|
||||||
|
import java.sql.Array;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TedStructuredSearchRepository {
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public List<DocumentSummary> search(SearchRequest request, int page, int size) {
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(p.legacy_procurement_document_id, p.document_id) AS id,
|
||||||
|
p.publication_id,
|
||||||
|
p.notice_id,
|
||||||
|
CAST(p.notice_type AS text) AS notice_type,
|
||||||
|
p.project_title,
|
||||||
|
p.buyer_name,
|
||||||
|
p.buyer_country_code,
|
||||||
|
p.buyer_city,
|
||||||
|
CAST(p.contract_nature AS text) AS contract_nature,
|
||||||
|
CAST(p.procedure_type AS text) AS procedure_type,
|
||||||
|
p.publication_date,
|
||||||
|
p.submission_deadline,
|
||||||
|
p.cpv_codes,
|
||||||
|
p.total_lots,
|
||||||
|
p.estimated_value,
|
||||||
|
p.estimated_value_currency
|
||||||
|
FROM ted.ted_notice_projection p
|
||||||
|
WHERE 1=1
|
||||||
|
""");
|
||||||
|
|
||||||
|
MapSqlParameterSource params = new MapSqlParameterSource();
|
||||||
|
appendFilters(sql, params, request);
|
||||||
|
sql.append(" ORDER BY ").append(resolveSortColumn(request.getSortBy())).append(' ')
|
||||||
|
.append(resolveSortDirection(request.getSortDirection()))
|
||||||
|
.append(", p.publication_date DESC NULLS LAST, p.publication_id DESC NULLS LAST, p.document_id ASC");
|
||||||
|
sql.append(" LIMIT :limit OFFSET :offset");
|
||||||
|
params.addValue("limit", size);
|
||||||
|
params.addValue("offset", page * size);
|
||||||
|
|
||||||
|
return jdbcTemplate.query(sql.toString(), params, new DocumentSummaryRowMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
public long count(SearchRequest request) {
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM ted.ted_notice_projection p
|
||||||
|
WHERE 1=1
|
||||||
|
""");
|
||||||
|
MapSqlParameterSource params = new MapSqlParameterSource();
|
||||||
|
appendFilters(sql, params, request);
|
||||||
|
Long value = jdbcTemplate.queryForObject(sql.toString(), params, Long.class);
|
||||||
|
return value == null ? 0L : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendFilters(StringBuilder sql, MapSqlParameterSource params, SearchRequest request) {
|
||||||
|
if (hasText(request.getCountryCode())) {
|
||||||
|
sql.append(" AND p.buyer_country_code = :countryCode");
|
||||||
|
params.addValue("countryCode", request.getCountryCode());
|
||||||
|
}
|
||||||
|
if (!CollectionUtils.isEmpty(request.getCountryCodes())) {
|
||||||
|
sql.append(" AND p.buyer_country_code IN (:countryCodes)");
|
||||||
|
params.addValue("countryCodes", request.getCountryCodes());
|
||||||
|
}
|
||||||
|
if (request.getNoticeType() != null) {
|
||||||
|
sql.append(" AND CAST(p.notice_type AS text) = :noticeType");
|
||||||
|
params.addValue("noticeType", request.getNoticeType().name());
|
||||||
|
}
|
||||||
|
if (request.getContractNature() != null) {
|
||||||
|
sql.append(" AND CAST(p.contract_nature AS text) = :contractNature");
|
||||||
|
params.addValue("contractNature", request.getContractNature().name());
|
||||||
|
}
|
||||||
|
if (request.getProcedureType() != null) {
|
||||||
|
sql.append(" AND CAST(p.procedure_type AS text) = :procedureType");
|
||||||
|
params.addValue("procedureType", request.getProcedureType().name());
|
||||||
|
}
|
||||||
|
if (hasText(request.getCpvPrefix())) {
|
||||||
|
sql.append(" AND EXISTS (SELECT 1 FROM unnest(p.cpv_codes) code WHERE code LIKE :cpvPrefixLike)");
|
||||||
|
params.addValue("cpvPrefixLike", request.getCpvPrefix() + "%");
|
||||||
|
}
|
||||||
|
if (!CollectionUtils.isEmpty(request.getCpvCodes())) {
|
||||||
|
sql.append(" AND EXISTS (SELECT 1 FROM unnest(p.cpv_codes) code WHERE code IN (:cpvCodes))");
|
||||||
|
params.addValue("cpvCodes", request.getCpvCodes());
|
||||||
|
}
|
||||||
|
if (hasText(request.getNutsCode())) {
|
||||||
|
sql.append(" AND (p.buyer_nuts_code = :nutsCode OR EXISTS (SELECT 1 FROM unnest(p.nuts_codes) code WHERE code = :nutsCode))");
|
||||||
|
params.addValue("nutsCode", request.getNutsCode());
|
||||||
|
}
|
||||||
|
if (!CollectionUtils.isEmpty(request.getNutsCodes())) {
|
||||||
|
sql.append(" AND (p.buyer_nuts_code IN (:nutsCodes) OR EXISTS (SELECT 1 FROM unnest(p.nuts_codes) code WHERE code IN (:nutsCodes)))");
|
||||||
|
params.addValue("nutsCodes", request.getNutsCodes());
|
||||||
|
}
|
||||||
|
if (request.getPublicationDateFrom() != null) {
|
||||||
|
sql.append(" AND p.publication_date >= :publicationDateFrom");
|
||||||
|
params.addValue("publicationDateFrom", request.getPublicationDateFrom());
|
||||||
|
}
|
||||||
|
if (request.getPublicationDateTo() != null) {
|
||||||
|
sql.append(" AND p.publication_date <= :publicationDateTo");
|
||||||
|
params.addValue("publicationDateTo", request.getPublicationDateTo());
|
||||||
|
}
|
||||||
|
if (request.getSubmissionDeadlineAfter() != null) {
|
||||||
|
sql.append(" AND p.submission_deadline > :submissionDeadlineAfter");
|
||||||
|
params.addValue("submissionDeadlineAfter", request.getSubmissionDeadlineAfter());
|
||||||
|
}
|
||||||
|
if (request.getEuFunded() != null) {
|
||||||
|
sql.append(" AND p.eu_funded = :euFunded");
|
||||||
|
params.addValue("euFunded", request.getEuFunded());
|
||||||
|
}
|
||||||
|
if (hasText(request.getBuyerNameContains())) {
|
||||||
|
sql.append(" AND LOWER(COALESCE(p.buyer_name, '')) LIKE :buyerNameContains");
|
||||||
|
params.addValue("buyerNameContains", like(request.getBuyerNameContains()));
|
||||||
|
}
|
||||||
|
if (hasText(request.getProjectTitleContains())) {
|
||||||
|
sql.append(" AND LOWER(COALESCE(p.project_title, '')) LIKE :projectTitleContains");
|
||||||
|
params.addValue("projectTitleContains", like(request.getProjectTitleContains()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSortColumn(String sortBy) {
|
||||||
|
if (sortBy == null || sortBy.isBlank()) {
|
||||||
|
return "p.publication_date";
|
||||||
|
}
|
||||||
|
return switch (sortBy) {
|
||||||
|
case "submissionDeadline" -> "p.submission_deadline";
|
||||||
|
case "buyerName" -> "p.buyer_name";
|
||||||
|
case "projectTitle" -> "p.project_title";
|
||||||
|
case "publicationDate" -> "p.publication_date";
|
||||||
|
default -> "p.publication_date";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSortDirection(String direction) {
|
||||||
|
return "asc".equalsIgnoreCase(direction) ? "ASC" : "DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String like(String value) {
|
||||||
|
return "%" + value.toLowerCase() + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class DocumentSummaryRowMapper implements RowMapper<DocumentSummary> {
|
||||||
|
@Override
|
||||||
|
public DocumentSummary mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return DocumentSummary.builder()
|
||||||
|
.id(rs.getObject("id", java.util.UUID.class))
|
||||||
|
.publicationId(rs.getString("publication_id"))
|
||||||
|
.noticeId(rs.getString("notice_id"))
|
||||||
|
.noticeType(parseNoticeType(rs.getString("notice_type")))
|
||||||
|
.projectTitle(rs.getString("project_title"))
|
||||||
|
.buyerName(rs.getString("buyer_name"))
|
||||||
|
.buyerCountryCode(rs.getString("buyer_country_code"))
|
||||||
|
.buyerCity(rs.getString("buyer_city"))
|
||||||
|
.contractNature(parseContractNature(rs.getString("contract_nature")))
|
||||||
|
.procedureType(parseProcedureType(rs.getString("procedure_type")))
|
||||||
|
.publicationDate(rs.getObject("publication_date", java.time.LocalDate.class))
|
||||||
|
.submissionDeadline(rs.getObject("submission_deadline", java.time.OffsetDateTime.class))
|
||||||
|
.cpvCodes(readArray(rs, "cpv_codes"))
|
||||||
|
.totalLots((Integer) rs.getObject("total_lots"))
|
||||||
|
.estimatedValue(rs.getBigDecimal("estimated_value"))
|
||||||
|
.estimatedValueCurrency(rs.getString("estimated_value_currency"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> readArray(ResultSet rs, String column) throws SQLException {
|
||||||
|
Array array = rs.getArray(column);
|
||||||
|
if (array == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Object value = array.getArray();
|
||||||
|
if (value instanceof String[] strings) {
|
||||||
|
return Arrays.asList(strings);
|
||||||
|
}
|
||||||
|
if (value instanceof Object[] objects) {
|
||||||
|
return Arrays.stream(objects).map(String::valueOf).toList();
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NoticeType parseNoticeType(String value) {
|
||||||
|
return value == null ? null : NoticeType.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContractNature parseContractNature(String value) {
|
||||||
|
return value == null ? null : ContractNature.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcedureType parseProcedureType(String value) {
|
||||||
|
return value == null ? null : ProcedureType.valueOf(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package at.procon.dip.domain.ted.service;
|
||||||
|
|
||||||
|
import at.procon.dip.domain.ted.search.TedStructuredSearchRepository;
|
||||||
|
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
|
||||||
|
import at.procon.dip.runtime.config.RuntimeMode;
|
||||||
|
import at.procon.dip.search.config.DipSearchProperties;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.SearchRequest;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.SearchResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class TedStructuredSearchService {
|
||||||
|
|
||||||
|
private final TedStructuredSearchRepository repository;
|
||||||
|
private final DipSearchProperties searchProperties;
|
||||||
|
|
||||||
|
public SearchResponse search(SearchRequest request) {
|
||||||
|
int page = request.getPage() != null ? Math.max(request.getPage(), 0) : 0;
|
||||||
|
int size = Math.min(
|
||||||
|
request.getSize() != null ? Math.max(request.getSize(), 1) : searchProperties.getDefaultPageSize(),
|
||||||
|
searchProperties.getMaxPageSize()
|
||||||
|
);
|
||||||
|
|
||||||
|
var documents = repository.search(request, page, size);
|
||||||
|
long totalElements = repository.count(request);
|
||||||
|
int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size);
|
||||||
|
|
||||||
|
return SearchResponse.builder()
|
||||||
|
.documents(documents)
|
||||||
|
.page(page)
|
||||||
|
.size(size)
|
||||||
|
.totalElements(totalElements)
|
||||||
|
.totalPages(totalPages)
|
||||||
|
.hasNext(page < totalPages - 1)
|
||||||
|
.hasPrevious(page > 0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
package at.procon.dip.domain.ted.web;
|
||||||
|
|
||||||
|
import at.procon.dip.domain.ted.service.TedStructuredSearchService;
|
||||||
|
import at.procon.dip.runtime.condition.ConditionalOnRuntimeMode;
|
||||||
|
import at.procon.dip.runtime.config.RuntimeMode;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.SearchRequest;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos.SearchResponse;
|
||||||
|
import at.procon.ted.model.entity.ContractNature;
|
||||||
|
import at.procon.ted.model.entity.NoticeType;
|
||||||
|
import at.procon.ted.model.entity.ProcedureType;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/v1/documents")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@ConditionalOnRuntimeMode(RuntimeMode.NEW)
|
||||||
|
public class TedStructuredSearchController {
|
||||||
|
|
||||||
|
private final TedStructuredSearchService searchService;
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
public ResponseEntity<SearchResponse> searchDocuments(
|
||||||
|
@RequestParam(required = false) String countryCode,
|
||||||
|
@RequestParam(required = false) List<String> countryCodes,
|
||||||
|
@RequestParam(required = false) NoticeType noticeType,
|
||||||
|
@RequestParam(required = false) ContractNature contractNature,
|
||||||
|
@RequestParam(required = false) ProcedureType procedureType,
|
||||||
|
@RequestParam(required = false) String cpvPrefix,
|
||||||
|
@RequestParam(required = false) List<String> cpvCodes,
|
||||||
|
@RequestParam(required = false) String nutsCode,
|
||||||
|
@RequestParam(required = false) List<String> nutsCodes,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate publicationDateFrom,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate publicationDateTo,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime submissionDeadlineAfter,
|
||||||
|
@RequestParam(required = false) Boolean euFunded,
|
||||||
|
@RequestParam(required = false) String buyerNameContains,
|
||||||
|
@RequestParam(required = false) String projectTitleContains,
|
||||||
|
@RequestParam(required = false) String q,
|
||||||
|
@RequestParam(required = false, defaultValue = "0.7") Double similarityThreshold,
|
||||||
|
@RequestParam(required = false, defaultValue = "0") Integer page,
|
||||||
|
@RequestParam(required = false, defaultValue = "20") Integer size,
|
||||||
|
@RequestParam(required = false, defaultValue = "publicationDate") String sortBy,
|
||||||
|
@RequestParam(required = false, defaultValue = "desc") String sortDirection) {
|
||||||
|
|
||||||
|
SearchRequest request = SearchRequest.builder()
|
||||||
|
.countryCode(countryCode)
|
||||||
|
.countryCodes(countryCodes)
|
||||||
|
.noticeType(noticeType)
|
||||||
|
.contractNature(contractNature)
|
||||||
|
.procedureType(procedureType)
|
||||||
|
.cpvPrefix(cpvPrefix)
|
||||||
|
.cpvCodes(cpvCodes)
|
||||||
|
.nutsCode(nutsCode)
|
||||||
|
.nutsCodes(nutsCodes)
|
||||||
|
.publicationDateFrom(publicationDateFrom)
|
||||||
|
.publicationDateTo(publicationDateTo)
|
||||||
|
.submissionDeadlineAfter(submissionDeadlineAfter)
|
||||||
|
.euFunded(euFunded)
|
||||||
|
.buyerNameContains(buyerNameContains)
|
||||||
|
.projectTitleContains(projectTitleContains)
|
||||||
|
.semanticQuery(q)
|
||||||
|
.similarityThreshold(similarityThreshold)
|
||||||
|
.page(page)
|
||||||
|
.size(size)
|
||||||
|
.sortBy(sortBy)
|
||||||
|
.sortDirection(sortDirection)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.debug("NEW runtime TED structured search request: {}", request);
|
||||||
|
return ResponseEntity.ok(searchService.search(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/search")
|
||||||
|
public ResponseEntity<SearchResponse> searchDocumentsPost(@RequestBody SearchRequest request) {
|
||||||
|
log.debug("NEW runtime TED structured search request (POST): {}", request);
|
||||||
|
return ResponseEntity.ok(searchService.search(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package at.procon.dip.domain.ted.search.integration;
|
||||||
|
|
||||||
|
import at.procon.dip.domain.access.DocumentVisibility;
|
||||||
|
import at.procon.dip.domain.document.DocumentFamily;
|
||||||
|
import at.procon.dip.domain.document.DocumentStatus;
|
||||||
|
import at.procon.dip.domain.document.DocumentType;
|
||||||
|
import at.procon.dip.domain.document.entity.Document;
|
||||||
|
import at.procon.dip.domain.ted.entity.TedNoticeProjection;
|
||||||
|
import at.procon.dip.testsupport.AbstractTedStructuredSearchIntegrationTest;
|
||||||
|
import at.procon.ted.model.entity.ContractNature;
|
||||||
|
import at.procon.ted.model.entity.NoticeType;
|
||||||
|
import at.procon.ted.model.entity.ProcedureType;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
class TedStructuredSearchEndpointIntegrationTest extends AbstractTedStructuredSearchIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSearch_should_filter_and_sort_ted_projection_results() throws Exception {
|
||||||
|
createProjection(UUID.randomUUID(), "00786665-2025", "AUT", NoticeType.CONTRACT_NOTICE,
|
||||||
|
ContractNature.SUPPLIES, ProcedureType.OPEN, "City of Vienna", "Medical gloves framework",
|
||||||
|
LocalDate.of(2025, 1, 15), OffsetDateTime.parse("2025-02-15T12:00:00Z"), new String[]{"33140000"}, new String[]{"AT130"}, true);
|
||||||
|
createProjection(UUID.randomUUID(), "00786666-2025", "DEU", NoticeType.CONTRACT_NOTICE,
|
||||||
|
ContractNature.SERVICES, ProcedureType.RESTRICTED, "Berlin Utilities", "Heating maintenance",
|
||||||
|
LocalDate.of(2025, 1, 10), OffsetDateTime.parse("2025-02-10T12:00:00Z"), new String[]{"50720000"}, new String[]{"DE300"}, false);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/v1/documents/search")
|
||||||
|
.param("countryCode", "AUT")
|
||||||
|
.param("noticeType", "CONTRACT_NOTICE")
|
||||||
|
.param("buyerNameContains", "vienna")
|
||||||
|
.param("sortBy", "publicationDate")
|
||||||
|
.param("sortDirection", "desc"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.documents.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.documents[0].publicationId").value("00786665-2025"))
|
||||||
|
.andExpect(jsonPath("$.documents[0].buyerName").value("City of Vienna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postSearch_should_support_cpv_and_nuts_filters() throws Exception {
|
||||||
|
createProjection(UUID.randomUUID(), "00786665-2025", "AUT", NoticeType.CONTRACT_NOTICE,
|
||||||
|
ContractNature.SUPPLIES, ProcedureType.OPEN, "City of Vienna", "Medical gloves framework",
|
||||||
|
LocalDate.of(2025, 1, 15), OffsetDateTime.parse("2025-02-15T12:00:00Z"), new String[]{"33140000", "33141000"}, new String[]{"AT130"}, true);
|
||||||
|
createProjection(UUID.randomUUID(), "00786666-2025", "AUT", NoticeType.CONTRACT_NOTICE,
|
||||||
|
ContractNature.SUPPLIES, ProcedureType.OPEN, "City of Graz", "Office supplies",
|
||||||
|
LocalDate.of(2025, 1, 16), OffsetDateTime.parse("2025-02-16T12:00:00Z"), new String[]{"30192000"}, new String[]{"AT221"}, true);
|
||||||
|
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"cpvPrefix": "3314",
|
||||||
|
"nutsCode": "AT130",
|
||||||
|
"page": 0,
|
||||||
|
"size": 10
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/v1/documents/search")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.documents.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.documents[0].publicationId").value("00786665-2025"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createProjection(UUID legacyId,
|
||||||
|
String publicationId,
|
||||||
|
String countryCode,
|
||||||
|
NoticeType noticeType,
|
||||||
|
ContractNature contractNature,
|
||||||
|
ProcedureType procedureType,
|
||||||
|
String buyerName,
|
||||||
|
String projectTitle,
|
||||||
|
LocalDate publicationDate,
|
||||||
|
OffsetDateTime submissionDeadline,
|
||||||
|
String[] cpvCodes,
|
||||||
|
String[] nutsCodes,
|
||||||
|
boolean euFunded) {
|
||||||
|
Document document = documentRepository.save(Document.builder()
|
||||||
|
.visibility(DocumentVisibility.PUBLIC)
|
||||||
|
.documentType(DocumentType.TED_NOTICE)
|
||||||
|
.documentFamily(DocumentFamily.PROCUREMENT)
|
||||||
|
.status(DocumentStatus.RECEIVED)
|
||||||
|
.title(projectTitle)
|
||||||
|
.summary(projectTitle)
|
||||||
|
.languageCode("en")
|
||||||
|
.mimeType("application/xml")
|
||||||
|
.businessKey(publicationId)
|
||||||
|
.dedupHash(publicationId)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
projectionRepository.save(TedNoticeProjection.builder()
|
||||||
|
.document(document)
|
||||||
|
.legacyProcurementDocumentId(legacyId)
|
||||||
|
.publicationId(publicationId)
|
||||||
|
.noticeId("NOTICE-" + publicationId)
|
||||||
|
.noticeType(noticeType)
|
||||||
|
.contractNature(contractNature)
|
||||||
|
.procedureType(procedureType)
|
||||||
|
.buyerCountryCode(countryCode)
|
||||||
|
.buyerName(buyerName)
|
||||||
|
.buyerCity("Vienna")
|
||||||
|
.buyerNutsCode(nutsCodes != null && nutsCodes.length > 0 ? nutsCodes[0] : null)
|
||||||
|
.projectTitle(projectTitle)
|
||||||
|
.projectDescription(projectTitle + " description")
|
||||||
|
.publicationDate(publicationDate)
|
||||||
|
.submissionDeadline(submissionDeadline)
|
||||||
|
.cpvCodes(cpvCodes)
|
||||||
|
.nutsCodes(nutsCodes)
|
||||||
|
.totalLots(1)
|
||||||
|
.estimatedValue(new BigDecimal("1000.00"))
|
||||||
|
.estimatedValueCurrency("EUR")
|
||||||
|
.euFunded(euFunded)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
package at.procon.dip.domain.ted.search.integration;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import at.procon.dip.domain.access.DocumentVisibility;
|
||||||
|
import at.procon.dip.domain.document.DocumentFamily;
|
||||||
|
import at.procon.dip.domain.document.DocumentStatus;
|
||||||
|
import at.procon.dip.domain.document.DocumentType;
|
||||||
|
import at.procon.dip.domain.document.entity.Document;
|
||||||
|
import at.procon.dip.domain.ted.entity.TedNoticeProjection;
|
||||||
|
import at.procon.dip.domain.ted.service.TedStructuredSearchService;
|
||||||
|
import at.procon.dip.testsupport.AbstractTedStructuredSearchIntegrationTest;
|
||||||
|
import at.procon.ted.config.TedProcessorProperties;
|
||||||
|
import at.procon.ted.model.dto.DocumentDtos;
|
||||||
|
import at.procon.ted.model.entity.ContractNature;
|
||||||
|
import at.procon.ted.model.entity.NoticeType;
|
||||||
|
import at.procon.ted.model.entity.ProcedureType;
|
||||||
|
import at.procon.ted.model.entity.ProcurementDocument;
|
||||||
|
import at.procon.ted.service.SearchService;
|
||||||
|
import at.procon.ted.service.VectorizationService;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
class TedStructuredSearchParityIntegrationTest extends AbstractTedStructuredSearchIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TedStructuredSearchService newSearchService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void new_structured_search_should_match_legacy_search_for_common_filters() {
|
||||||
|
createLegacyAndProjection("00786665-2025", "AUT", NoticeType.CONTRACT_NOTICE, ContractNature.SUPPLIES,
|
||||||
|
ProcedureType.OPEN, "City of Vienna", "Medical gloves framework",
|
||||||
|
LocalDate.of(2025, 1, 15), OffsetDateTime.parse("2025-02-15T12:00:00Z"), true,
|
||||||
|
new String[]{"33140000"}, new String[]{"AT130"});
|
||||||
|
createLegacyAndProjection("00786666-2025", "AUT", NoticeType.CONTRACT_NOTICE, ContractNature.SUPPLIES,
|
||||||
|
ProcedureType.OPEN, "City of Vienna", "Office furniture framework",
|
||||||
|
LocalDate.of(2025, 1, 10), OffsetDateTime.parse("2025-02-10T12:00:00Z"), false,
|
||||||
|
new String[]{"39130000"}, new String[]{"AT130"});
|
||||||
|
createLegacyAndProjection("00786667-2025", "DEU", NoticeType.CONTRACT_NOTICE, ContractNature.SERVICES,
|
||||||
|
ProcedureType.RESTRICTED, "Berlin Utilities", "Heating maintenance",
|
||||||
|
LocalDate.of(2025, 1, 12), OffsetDateTime.parse("2025-02-11T12:00:00Z"), true,
|
||||||
|
new String[]{"50720000"}, new String[]{"DE300"});
|
||||||
|
|
||||||
|
DocumentDtos.SearchRequest request = DocumentDtos.SearchRequest.builder()
|
||||||
|
.countryCode("AUT")
|
||||||
|
.noticeType(NoticeType.CONTRACT_NOTICE)
|
||||||
|
.contractNature(ContractNature.SUPPLIES)
|
||||||
|
.publicationDateFrom(LocalDate.of(2025, 1, 1))
|
||||||
|
.publicationDateTo(LocalDate.of(2025, 1, 31))
|
||||||
|
.buyerNameContains("vienna")
|
||||||
|
.page(0)
|
||||||
|
.size(20)
|
||||||
|
.sortBy("publicationDate")
|
||||||
|
.sortDirection("desc")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
DocumentDtos.SearchResponse newResponse = newSearchService.search(request);
|
||||||
|
DocumentDtos.SearchResponse legacyResponse = legacySearchService().search(request);
|
||||||
|
|
||||||
|
assertThat(newResponse.getTotalElements()).isEqualTo(legacyResponse.getTotalElements());
|
||||||
|
assertThat(newResponse.getDocuments().stream().map(DocumentDtos.DocumentSummary::getPublicationId).collect(Collectors.toList()))
|
||||||
|
.containsExactlyElementsOf(legacyResponse.getDocuments().stream().map(DocumentDtos.DocumentSummary::getPublicationId).collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SearchService legacySearchService() {
|
||||||
|
VectorizationService vectorizationService = mock(VectorizationService.class);
|
||||||
|
when(vectorizationService.isAvailable()).thenReturn(false);
|
||||||
|
TedProcessorProperties properties = new TedProcessorProperties();
|
||||||
|
properties.getSearch().setDefaultPageSize(20);
|
||||||
|
properties.getSearch().setMaxPageSize(100);
|
||||||
|
return new SearchService(procurementDocumentRepository, vectorizationService, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createLegacyAndProjection(String publicationId,
|
||||||
|
String countryCode,
|
||||||
|
NoticeType noticeType,
|
||||||
|
ContractNature contractNature,
|
||||||
|
ProcedureType procedureType,
|
||||||
|
String buyerName,
|
||||||
|
String projectTitle,
|
||||||
|
LocalDate publicationDate,
|
||||||
|
OffsetDateTime submissionDeadline,
|
||||||
|
boolean euFunded,
|
||||||
|
String[] cpvCodes,
|
||||||
|
String[] nutsCodes) {
|
||||||
|
ProcurementDocument legacy = procurementDocumentRepository.save(ProcurementDocument.builder()
|
||||||
|
.documentHash(publicationId + "-hash")
|
||||||
|
.publicationId(publicationId)
|
||||||
|
.noticeId("NOTICE-" + publicationId)
|
||||||
|
.noticeType(noticeType)
|
||||||
|
.contractNature(contractNature)
|
||||||
|
.procedureType(procedureType)
|
||||||
|
.buyerCountryCode(countryCode)
|
||||||
|
.buyerName(buyerName)
|
||||||
|
.buyerCity("Vienna")
|
||||||
|
.buyerNutsCode(nutsCodes != null && nutsCodes.length > 0 ? nutsCodes[0] : null)
|
||||||
|
.projectTitle(projectTitle)
|
||||||
|
.projectDescription(projectTitle + " description")
|
||||||
|
.publicationDate(publicationDate)
|
||||||
|
.submissionDeadline(submissionDeadline)
|
||||||
|
.cpvCodes(cpvCodes)
|
||||||
|
.nutsCodes(nutsCodes)
|
||||||
|
.totalLots(1)
|
||||||
|
.estimatedValue(new BigDecimal("1000.00"))
|
||||||
|
.estimatedValueCurrency("EUR")
|
||||||
|
.euFunded(euFunded)
|
||||||
|
.textContent(projectTitle)
|
||||||
|
.xmlDocument("<xml/>")
|
||||||
|
.sourceFilename(publicationId + ".xml")
|
||||||
|
.sourcePath("/tmp/" + publicationId + ".xml")
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Document document = documentRepository.save(Document.builder()
|
||||||
|
.visibility(DocumentVisibility.PUBLIC)
|
||||||
|
.documentType(DocumentType.TED_NOTICE)
|
||||||
|
.documentFamily(DocumentFamily.PROCUREMENT)
|
||||||
|
.status(DocumentStatus.RECEIVED)
|
||||||
|
.title(projectTitle)
|
||||||
|
.summary(projectTitle)
|
||||||
|
.languageCode("en")
|
||||||
|
.mimeType("application/xml")
|
||||||
|
.businessKey(publicationId)
|
||||||
|
.dedupHash(publicationId)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
projectionRepository.save(TedNoticeProjection.builder()
|
||||||
|
.document(document)
|
||||||
|
.legacyProcurementDocumentId(legacy.getId())
|
||||||
|
.publicationId(publicationId)
|
||||||
|
.noticeId(legacy.getNoticeId())
|
||||||
|
.noticeType(noticeType)
|
||||||
|
.contractNature(contractNature)
|
||||||
|
.procedureType(procedureType)
|
||||||
|
.buyerCountryCode(countryCode)
|
||||||
|
.buyerName(buyerName)
|
||||||
|
.buyerCity("Vienna")
|
||||||
|
.buyerNutsCode(nutsCodes != null && nutsCodes.length > 0 ? nutsCodes[0] : null)
|
||||||
|
.projectTitle(projectTitle)
|
||||||
|
.projectDescription(projectTitle + " description")
|
||||||
|
.publicationDate(publicationDate)
|
||||||
|
.submissionDeadline(submissionDeadline)
|
||||||
|
.cpvCodes(cpvCodes)
|
||||||
|
.nutsCodes(nutsCodes)
|
||||||
|
.totalLots(1)
|
||||||
|
.estimatedValue(new BigDecimal("1000.00"))
|
||||||
|
.estimatedValueCurrency("EUR")
|
||||||
|
.euFunded(euFunded)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package at.procon.dip.testsupport;
|
||||||
|
|
||||||
|
import at.procon.dip.FixedPortPostgreSQLContainer;
|
||||||
|
import at.procon.dip.domain.document.repository.DocumentRepository;
|
||||||
|
import at.procon.dip.domain.ted.repository.TedNoticeProjectionRepository;
|
||||||
|
import at.procon.ted.repository.ProcurementDocumentRepository;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.TestInstance;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
@SpringBootTest(classes = TedStructuredSearchTestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||||
|
@Testcontainers
|
||||||
|
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||||
|
"spring.jpa.show-sql=false",
|
||||||
|
"spring.jpa.open-in-view=false",
|
||||||
|
"spring.jpa.properties.hibernate.default_schema=DOC",
|
||||||
|
"spring.main.lazy-initialization=true",
|
||||||
|
"dip.runtime.mode=NEW",
|
||||||
|
"dip.search.default-page-size=20",
|
||||||
|
"dip.search.max-page-size=100"
|
||||||
|
})
|
||||||
|
public abstract class AbstractTedStructuredSearchIntegrationTest {
|
||||||
|
|
||||||
|
private static final int HOST_PORT = 15434;
|
||||||
|
private static final String DB_NAME = "dip_ted_structured_search_test";
|
||||||
|
private static final String DB_USER = "test";
|
||||||
|
private static final String DB_PASSWORD = "test";
|
||||||
|
private static final String JDBC_URL = "jdbc:postgresql://localhost:" + HOST_PORT + "/" + DB_NAME;
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new FixedPortPostgreSQLContainer<>("postgres:16-alpine", HOST_PORT)
|
||||||
|
.withDatabaseName(DB_NAME)
|
||||||
|
.withUsername(DB_USER)
|
||||||
|
.withPassword(DB_PASSWORD)
|
||||||
|
.withInitScript("sql/create-doc-search-test-schemas.sql");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void registerProperties(DynamicPropertyRegistry registry) {
|
||||||
|
if (!postgres.isRunning()) {
|
||||||
|
postgres.start();
|
||||||
|
}
|
||||||
|
registry.add("spring.datasource.url", () -> JDBC_URL);
|
||||||
|
registry.add("spring.datasource.username", () -> DB_USER);
|
||||||
|
registry.add("spring.datasource.password", () -> DB_PASSWORD);
|
||||||
|
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected DataSource dataSource;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected TedNoticeProjectionRepository projectionRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
protected ProcurementDocumentRepository procurementDocumentRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetDatabase() {
|
||||||
|
cleanupDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void cleanupDatabase() {
|
||||||
|
jdbcTemplate.execute("TRUNCATE TABLE ted.ted_notice_lot, ted.ted_notice_organization, ted.ted_notice_projection, ted.procurement_lot, ted.organization, ted.procurement_document, doc.doc_document, doc.doc_tenant RESTART IDENTITY CASCADE");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package at.procon.dip.testsupport;
|
||||||
|
|
||||||
|
import at.procon.dip.config.JacksonConfig;
|
||||||
|
import at.procon.dip.domain.ted.search.TedStructuredSearchRepository;
|
||||||
|
import at.procon.dip.domain.ted.service.TedStructuredSearchService;
|
||||||
|
import at.procon.dip.domain.ted.web.TedStructuredSearchController;
|
||||||
|
import at.procon.dip.search.config.DipSearchProperties;
|
||||||
|
import org.springframework.boot.SpringBootConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
|
||||||
|
@SpringBootConfiguration
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ImportAutoConfiguration({
|
||||||
|
JacksonAutoConfiguration.class,
|
||||||
|
HttpMessageConvertersAutoConfiguration.class,
|
||||||
|
DataSourceAutoConfiguration.class,
|
||||||
|
HibernateJpaAutoConfiguration.class,
|
||||||
|
TransactionAutoConfiguration.class,
|
||||||
|
JdbcTemplateAutoConfiguration.class,
|
||||||
|
WebMvcAutoConfiguration.class
|
||||||
|
})
|
||||||
|
@EnableConfigurationProperties(DipSearchProperties.class)
|
||||||
|
@EntityScan(basePackages = {
|
||||||
|
"at.procon.dip.domain.document.entity",
|
||||||
|
"at.procon.dip.domain.tenant.entity",
|
||||||
|
"at.procon.dip.domain.ted.entity",
|
||||||
|
"at.procon.ted.model.entity"
|
||||||
|
})
|
||||||
|
@EnableJpaRepositories(basePackages = {
|
||||||
|
"at.procon.dip.domain.document.repository",
|
||||||
|
"at.procon.dip.domain.tenant.repository",
|
||||||
|
"at.procon.dip.domain.ted.repository",
|
||||||
|
"at.procon.ted.repository"
|
||||||
|
})
|
||||||
|
@Import({
|
||||||
|
JacksonConfig.class,
|
||||||
|
TedStructuredSearchRepository.class,
|
||||||
|
TedStructuredSearchService.class,
|
||||||
|
TedStructuredSearchController.class
|
||||||
|
})
|
||||||
|
public class TedStructuredSearchTestApplication {
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue