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