feat: initial nutri-collector — data collector service for supplement catalog
This commit is contained in:
commit
50a1d39893
|
|
@ -0,0 +1,10 @@
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY src ./src
|
||||||
|
RUN apk add --no-cache maven && \
|
||||||
|
mvn clean package -DskipTests -q
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/target/*.jar app.jar
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_NAME: nutri
|
||||||
|
DB_USER: nutri
|
||||||
|
DB_PASSWORD: nutri
|
||||||
|
COLLECTOR_INPUT_DIR: /data/input
|
||||||
|
SPRING_PROFILES_ACTIVE: dev
|
||||||
|
volumes:
|
||||||
|
- collector-data:/data/input
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: nutri
|
||||||
|
POSTGRES_USER: nutri
|
||||||
|
POSTGRES_PASSWORD: nutri
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U nutri -d nutri"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
collector-data:
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.4.4</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>ru.oa2.mvp</groupId>
|
||||||
|
<artifactId>nutri-collector</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
<name>nutri-collector</name>
|
||||||
|
<description>Data collector service for supplement (BAD) catalog — parses and normalizes supplement data</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<opencsv.version>5.9</opencsv.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Batch -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Liquibase -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.liquibase</groupId>
|
||||||
|
<artifactId>liquibase-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OpenCSV -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.opencsv</groupId>
|
||||||
|
<artifactId>opencsv</artifactId>
|
||||||
|
<version>${opencsv.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Actuator -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.batch</groupId>
|
||||||
|
<artifactId>spring-batch-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testcontainers -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>1.20.4</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package ru.oa2.mvp.nutricollector;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
public class NutriCollectorApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(NutriCollectorApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.batch.BatchDataSourceScriptDatabaseInitializer;
|
||||||
|
import org.springframework.boot.autoconfigure.batch.BatchProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class BatchConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public BatchDataSourceScriptDatabaseInitializer batchDataSourceInitializer(
|
||||||
|
DataSource dataSource, BatchProperties properties) {
|
||||||
|
return new BatchDataSourceScriptDatabaseInitializer(dataSource, properties.getJdbc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.service.CollectorService;
|
||||||
|
import ru.oa2.mvp.nutricollector.service.SupplementMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/collector")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CollectorController {
|
||||||
|
|
||||||
|
private final CollectorService collectorService;
|
||||||
|
private final SupplementMapper mapper;
|
||||||
|
|
||||||
|
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<CollectorRunResponse> uploadCsv(
|
||||||
|
@RequestParam("file") @NotNull MultipartFile file) throws IOException {
|
||||||
|
log.info("Received CSV upload: {}, size: {} bytes", file.getOriginalFilename(), file.getSize());
|
||||||
|
|
||||||
|
String source = "upload:" + (file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown");
|
||||||
|
CollectorRun run = collectorService.processCsvStream(file.getInputStream(), source);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.OK).body(mapper.toRunResponse(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/runs")
|
||||||
|
public ResponseEntity<List<CollectorRunResponse>> getRecentRuns() {
|
||||||
|
List<CollectorRunResponse> runs = collectorService.getRecentRuns().stream()
|
||||||
|
.map(mapper::toRunResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(runs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record CollectorRunResponse(
|
||||||
|
UUID id,
|
||||||
|
Instant runAt,
|
||||||
|
String source,
|
||||||
|
Integer added,
|
||||||
|
Integer updated,
|
||||||
|
Integer errors,
|
||||||
|
String status
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record IngredientData(
|
||||||
|
String name,
|
||||||
|
BigDecimal amount,
|
||||||
|
String unit,
|
||||||
|
Integer dailyValuePercent
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.dto;
|
||||||
|
|
||||||
|
import com.opencsv.bean.CsvBindByName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class SupplementCsvRecord {
|
||||||
|
|
||||||
|
@CsvBindByName(column = "name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "brand")
|
||||||
|
private String brand;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "category")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "form")
|
||||||
|
private String form;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "serving_size")
|
||||||
|
private String servingSize;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "servings_per_container")
|
||||||
|
private String servingsPerContainer;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "contraindications")
|
||||||
|
private String contraindications;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "country")
|
||||||
|
private String country;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "source_url")
|
||||||
|
private String sourceUrl;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "rating")
|
||||||
|
private String rating;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "price_range")
|
||||||
|
private String priceRange;
|
||||||
|
|
||||||
|
@CsvBindByName(column = "ingredients")
|
||||||
|
private String ingredients;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "collector_runs")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class CollectorRun {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", updatable = false, nullable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "run_at", nullable = false)
|
||||||
|
private Instant runAt;
|
||||||
|
|
||||||
|
@Column(name = "source")
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
@Column(name = "added")
|
||||||
|
private Integer added;
|
||||||
|
|
||||||
|
@Column(name = "updated")
|
||||||
|
private Integer updated;
|
||||||
|
|
||||||
|
@Column(name = "errors")
|
||||||
|
private Integer errors;
|
||||||
|
|
||||||
|
@Column(name = "status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
public void setRunAt(Instant runAt) { this.runAt = runAt; }
|
||||||
|
public void setSource(String source) { this.source = source; }
|
||||||
|
public void setAdded(Integer added) { this.added = added; }
|
||||||
|
public void setUpdated(Integer updated) { this.updated = updated; }
|
||||||
|
public void setErrors(Integer errors) { this.errors = errors; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "ingredients")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Ingredient {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", updatable = false, nullable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "amount", precision = 10, scale = 4)
|
||||||
|
private BigDecimal amount;
|
||||||
|
|
||||||
|
@Column(name = "unit")
|
||||||
|
private String unit;
|
||||||
|
|
||||||
|
@Column(name = "daily_value_percent")
|
||||||
|
private Integer dailyValuePercent;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "supplement_id", nullable = false)
|
||||||
|
@Setter
|
||||||
|
private Supplement supplement;
|
||||||
|
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||||
|
public void setUnit(String unit) { this.unit = unit; }
|
||||||
|
public void setDailyValuePercent(Integer dailyValuePercent) { this.dailyValuePercent = dailyValuePercent; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "supplements")
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Supplement {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", updatable = false, nullable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "brand")
|
||||||
|
private String brand;
|
||||||
|
|
||||||
|
@Column(name = "category")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Column(name = "form")
|
||||||
|
private String form;
|
||||||
|
|
||||||
|
@Column(name = "serving_size")
|
||||||
|
private String servingSize;
|
||||||
|
|
||||||
|
@Column(name = "servings_per_container")
|
||||||
|
private Integer servingsPerContainer;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "contraindications", columnDefinition = "TEXT")
|
||||||
|
private String contraindications;
|
||||||
|
|
||||||
|
@Column(name = "country")
|
||||||
|
private String country;
|
||||||
|
|
||||||
|
@Column(name = "image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@Column(name = "source_url")
|
||||||
|
private String sourceUrl;
|
||||||
|
|
||||||
|
@Column(name = "rating", precision = 3, scale = 2)
|
||||||
|
private BigDecimal rating;
|
||||||
|
|
||||||
|
@Column(name = "price_range")
|
||||||
|
private String priceRange;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "supplement", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@Builder.Default
|
||||||
|
private List<Ingredient> ingredients = new ArrayList<>();
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public void setBrand(String brand) { this.brand = brand; }
|
||||||
|
public void setCategory(String category) { this.category = category; }
|
||||||
|
public void setForm(String form) { this.form = form; }
|
||||||
|
public void setServingSize(String servingSize) { this.servingSize = servingSize; }
|
||||||
|
public void setServingsPerContainer(Integer servingsPerContainer) { this.servingsPerContainer = servingsPerContainer; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public void setContraindications(String contraindications) { this.contraindications = contraindications; }
|
||||||
|
public void setCountry(String country) { this.country = country; }
|
||||||
|
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||||
|
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
|
||||||
|
public void setRating(BigDecimal rating) { this.rating = rating; }
|
||||||
|
public void setPriceRange(String priceRange) { this.priceRange = priceRange; }
|
||||||
|
|
||||||
|
public void addIngredient(Ingredient ingredient) {
|
||||||
|
ingredients.add(ingredient);
|
||||||
|
ingredient.setSupplement(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeIngredient(Ingredient ingredient) {
|
||||||
|
ingredients.remove(ingredient);
|
||||||
|
ingredient.setSupplement(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.exception;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Slf4j
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
FieldError::getField,
|
||||||
|
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
|
||||||
|
(a, b) -> a));
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
|
||||||
|
problem.setTitle("Validation Error");
|
||||||
|
problem.setProperty("errors", errors);
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
problem.setTitle("Bad Request");
|
||||||
|
problem.setProperty("timestamp", Instant.now());
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ProblemDetail handleGeneric(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
|
||||||
|
problem.setTitle("Internal Server Error");
|
||||||
|
return problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface CollectorRunRepository extends JpaRepository<CollectorRun, UUID> {
|
||||||
|
|
||||||
|
List<CollectorRun> findTop10ByOrderByRunAtDesc();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Ingredient;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface IngredientRepository extends JpaRepository<Ingredient, UUID> {
|
||||||
|
|
||||||
|
List<Ingredient> findBySupplementId(UUID supplementId);
|
||||||
|
|
||||||
|
void deleteBySupplementId(UUID supplementId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Supplement;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SupplementRepository extends JpaRepository<Supplement, UUID> {
|
||||||
|
|
||||||
|
Optional<Supplement> findByNameAndBrand(String name, String brand);
|
||||||
|
|
||||||
|
Optional<Supplement> findBySourceUrl(String sourceUrl);
|
||||||
|
|
||||||
|
boolean existsBySourceUrl(String sourceUrl);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import com.opencsv.bean.CsvToBeanBuilder;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.IngredientData;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Ingredient;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Supplement;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.CollectorRunRepository;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.IngredientRepository;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.SupplementRepository;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CollectorService {
|
||||||
|
|
||||||
|
private final SupplementRepository supplementRepository;
|
||||||
|
private final IngredientRepository ingredientRepository;
|
||||||
|
private final CollectorRunRepository collectorRunRepository;
|
||||||
|
private final SupplementMapper mapper;
|
||||||
|
private final IngredientParser ingredientParser;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CollectorRun processCsvStream(InputStream inputStream, String source) {
|
||||||
|
log.info("Starting collection from source: {}", source);
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
int updated = 0;
|
||||||
|
int errors = 0;
|
||||||
|
|
||||||
|
try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
|
||||||
|
List<SupplementCsvRecord> records = new CsvToBeanBuilder<SupplementCsvRecord>(reader)
|
||||||
|
.withType(SupplementCsvRecord.class)
|
||||||
|
.withIgnoreLeadingWhiteSpace(true)
|
||||||
|
.withIgnoreEmptyLine(true)
|
||||||
|
.build()
|
||||||
|
.parse();
|
||||||
|
|
||||||
|
log.info("Parsed {} CSV records from source: {}", records.size(), source);
|
||||||
|
|
||||||
|
for (SupplementCsvRecord record : records) {
|
||||||
|
try {
|
||||||
|
if (record.getName() == null || record.getName().isBlank()) {
|
||||||
|
log.warn("Skipping record with empty name");
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isUpdate = processRecord(record);
|
||||||
|
if (isUpdate) {
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing record '{}': {}", record.getName(), e.getMessage());
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to read CSV from source: {} — {}", source, e.getMessage());
|
||||||
|
return saveRun(source, added, updated, errors, "FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
String status = errors > 0 ? "COMPLETED_WITH_ERRORS" : "SUCCESS";
|
||||||
|
log.info("Collection complete for source '{}': added={}, updated={}, errors={}, status={}",
|
||||||
|
source, added, updated, errors, status);
|
||||||
|
|
||||||
|
return saveRun(source, added, updated, errors, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean processRecord(SupplementCsvRecord record) {
|
||||||
|
var existing = supplementRepository.findBySourceUrl(record.getSourceUrl());
|
||||||
|
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
Supplement supplement = existing.get();
|
||||||
|
mapper.updateFromCsvRecord(supplement, record);
|
||||||
|
updateIngredients(supplement, record.getIngredients());
|
||||||
|
supplementRepository.save(supplement);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Supplement supplement = mapper.fromCsvRecord(record);
|
||||||
|
supplement = supplementRepository.save(supplement);
|
||||||
|
updateIngredients(supplement, record.getIngredients());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateIngredients(Supplement supplement, String ingredientsString) {
|
||||||
|
ingredientRepository.deleteBySupplementId(supplement.getId());
|
||||||
|
ingredientRepository.flush();
|
||||||
|
|
||||||
|
List<IngredientData> ingredientDataList = ingredientParser.parse(ingredientsString);
|
||||||
|
for (IngredientData data : ingredientDataList) {
|
||||||
|
Ingredient ingredient = mapper.fromIngredientData(data);
|
||||||
|
supplement.addIngredient(ingredient);
|
||||||
|
ingredientRepository.save(ingredient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CollectorRun saveRun(String source, int added, int updated, int errors, String status) {
|
||||||
|
CollectorRun run = CollectorRun.builder()
|
||||||
|
.runAt(Instant.now())
|
||||||
|
.source(source)
|
||||||
|
.added(added)
|
||||||
|
.updated(updated)
|
||||||
|
.errors(errors)
|
||||||
|
.status(status)
|
||||||
|
.build();
|
||||||
|
return collectorRunRepository.save(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CollectorRun> getRecentRuns() {
|
||||||
|
return collectorRunRepository.findTop10ByOrderByRunAtDesc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class FileCollectorScheduler {
|
||||||
|
|
||||||
|
private final CollectorService collectorService;
|
||||||
|
|
||||||
|
@Value("${collector.input-dir:#{null}}")
|
||||||
|
private String inputDir;
|
||||||
|
|
||||||
|
@Scheduled(cron = "${collector.schedule:0 0 */6 * * *}")
|
||||||
|
public void collectFromFiles() {
|
||||||
|
if (inputDir == null || inputDir.isBlank()) {
|
||||||
|
log.debug("No input directory configured (collector.input-dir), skipping file collection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Path dir = Path.of(inputDir);
|
||||||
|
if (!Files.isDirectory(dir)) {
|
||||||
|
log.warn("Input directory does not exist: {}", inputDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Scanning input directory for CSV files: {}", inputDir);
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.csv")) {
|
||||||
|
for (Path csvFile : stream) {
|
||||||
|
processFile(csvFile);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Error scanning input directory: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processFile(Path csvFile) {
|
||||||
|
String filename = csvFile.getFileName().toString();
|
||||||
|
log.info("Processing file: {}", filename);
|
||||||
|
|
||||||
|
try (InputStream is = Files.newInputStream(csvFile)) {
|
||||||
|
collectorService.processCsvStream(is, "file:" + filename);
|
||||||
|
|
||||||
|
Path processedDir = csvFile.getParent().resolve("processed");
|
||||||
|
Files.createDirectories(processedDir);
|
||||||
|
Files.move(csvFile, processedDir.resolve(filename));
|
||||||
|
log.info("Moved processed file to: processed/{}", filename);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Error processing file '{}': {}", filename, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.IngredientData;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class IngredientParser {
|
||||||
|
|
||||||
|
private static final Pattern INGREDIENT_WITH_AMOUNT = Pattern.compile(
|
||||||
|
"(.+?)\\s*[-–:]\\s*(\\d+(?:[.,]\\d+)?)\\s*(\\w+)?\\s*(?:\\((\\d+)%\\s*DV\\))?\\s*$");
|
||||||
|
private static final Pattern INGREDIENT_NAME_ONLY = Pattern.compile(
|
||||||
|
"(.+?)\\s*(?:\\((\\d+)%\\s*DV\\))?\\s*$");
|
||||||
|
|
||||||
|
public List<IngredientData> parse(String ingredientsString) {
|
||||||
|
if (ingredientsString == null || ingredientsString.isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<IngredientData> result = new ArrayList<>();
|
||||||
|
String[] parts = ingredientsString.split("[;|]");
|
||||||
|
|
||||||
|
for (String part : parts) {
|
||||||
|
String trimmed = part.trim();
|
||||||
|
if (trimmed.isEmpty()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Matcher matcher = INGREDIENT_WITH_AMOUNT.matcher(trimmed);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
String name = matcher.group(1).trim();
|
||||||
|
BigDecimal amount = parseBigDecimal(matcher.group(2));
|
||||||
|
String unit = matcher.group(3);
|
||||||
|
Integer dailyValue = parseInteger(matcher.group(4));
|
||||||
|
if (!name.isEmpty()) {
|
||||||
|
result.add(new IngredientData(name, amount, unit, dailyValue));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Matcher nameMatcher = INGREDIENT_NAME_ONLY.matcher(trimmed);
|
||||||
|
if (nameMatcher.matches()) {
|
||||||
|
String name = nameMatcher.group(1).trim();
|
||||||
|
Integer dailyValue = parseInteger(nameMatcher.group(2));
|
||||||
|
if (!name.isEmpty()) {
|
||||||
|
result.add(new IngredientData(name, null, null, dailyValue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse ingredient: '{}' — {}", trimmed, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parseBigDecimal(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return new BigDecimal(value.replace(',', '.').trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseInteger(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.IngredientData;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Ingredient;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Supplement;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SupplementMapper {
|
||||||
|
|
||||||
|
public Supplement fromCsvRecord(SupplementCsvRecord csv) {
|
||||||
|
Supplement supplement = Supplement.builder()
|
||||||
|
.name(csv.getName())
|
||||||
|
.brand(csv.getBrand())
|
||||||
|
.category(csv.getCategory())
|
||||||
|
.form(csv.getForm())
|
||||||
|
.servingSize(csv.getServingSize())
|
||||||
|
.servingsPerContainer(parseInteger(csv.getServingsPerContainer()))
|
||||||
|
.description(csv.getDescription())
|
||||||
|
.contraindications(csv.getContraindications())
|
||||||
|
.country(csv.getCountry())
|
||||||
|
.imageUrl(csv.getImageUrl())
|
||||||
|
.sourceUrl(csv.getSourceUrl())
|
||||||
|
.rating(parseBigDecimal(csv.getRating()))
|
||||||
|
.priceRange(csv.getPriceRange())
|
||||||
|
.build();
|
||||||
|
return supplement;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateFromCsvRecord(Supplement existing, SupplementCsvRecord csv) {
|
||||||
|
existing.setName(csv.getName());
|
||||||
|
existing.setBrand(csv.getBrand());
|
||||||
|
existing.setCategory(csv.getCategory());
|
||||||
|
existing.setForm(csv.getForm());
|
||||||
|
existing.setServingSize(csv.getServingSize());
|
||||||
|
existing.setServingsPerContainer(parseInteger(csv.getServingsPerContainer()));
|
||||||
|
existing.setDescription(csv.getDescription());
|
||||||
|
existing.setContraindications(csv.getContraindications());
|
||||||
|
existing.setCountry(csv.getCountry());
|
||||||
|
existing.setImageUrl(csv.getImageUrl());
|
||||||
|
existing.setSourceUrl(csv.getSourceUrl());
|
||||||
|
existing.setRating(parseBigDecimal(csv.getRating()));
|
||||||
|
existing.setPriceRange(csv.getPriceRange());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Ingredient fromIngredientData(IngredientData data) {
|
||||||
|
return Ingredient.builder()
|
||||||
|
.name(data.name())
|
||||||
|
.amount(data.amount())
|
||||||
|
.unit(data.unit())
|
||||||
|
.dailyValuePercent(data.dailyValuePercent())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CollectorRunResponse toRunResponse(CollectorRun run) {
|
||||||
|
return new CollectorRunResponse(
|
||||||
|
run.getId(),
|
||||||
|
run.getRunAt(),
|
||||||
|
run.getSource(),
|
||||||
|
run.getAdded(),
|
||||||
|
run.getUpdated(),
|
||||||
|
run.getErrors(),
|
||||||
|
run.getStatus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseInteger(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parseBigDecimal(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return new BigDecimal(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
spring:
|
||||||
|
jpa:
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
show-sql: true
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
ru.oa2.mvp.nutricollector: DEBUG
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 10
|
||||||
|
minimum-idle: 2
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
ru.oa2.mvp.nutricollector: INFO
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: nutri-collector
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:nutri}
|
||||||
|
username: ${DB_USER:nutri}
|
||||||
|
password: ${DB_PASSWORD:nutri}
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 5
|
||||||
|
minimum-idle: 1
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
open-in-view: false
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
default_schema: public
|
||||||
|
format_sql: true
|
||||||
|
|
||||||
|
liquibase:
|
||||||
|
change-log: classpath:db/changelog/db.changelog-master.yaml
|
||||||
|
|
||||||
|
batch:
|
||||||
|
jdbc:
|
||||||
|
initialize-schema: always
|
||||||
|
job:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 50MB
|
||||||
|
max-request-size: 50MB
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8081}
|
||||||
|
|
||||||
|
collector:
|
||||||
|
input-dir: ${COLLECTOR_INPUT_DIR:}
|
||||||
|
schedule: ${COLLECTOR_SCHEDULE:0 0 */6 * * *}
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when_authorized
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
ru.oa2.mvp.nutricollector: INFO
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
org.springframework.batch: WARN
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 001-create-supplements-table
|
||||||
|
author: backend-agent
|
||||||
|
preConditions:
|
||||||
|
- onFail: MARK_RAN
|
||||||
|
- not:
|
||||||
|
- tableExists:
|
||||||
|
tableName: supplements
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: supplements
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: uuid
|
||||||
|
defaultValueComputed: gen_random_uuid()
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: name
|
||||||
|
type: varchar(500)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: brand
|
||||||
|
type: varchar(255)
|
||||||
|
- column:
|
||||||
|
name: category
|
||||||
|
type: varchar(255)
|
||||||
|
- column:
|
||||||
|
name: form
|
||||||
|
type: varchar(100)
|
||||||
|
- column:
|
||||||
|
name: serving_size
|
||||||
|
type: varchar(100)
|
||||||
|
- column:
|
||||||
|
name: servings_per_container
|
||||||
|
type: int
|
||||||
|
- column:
|
||||||
|
name: description
|
||||||
|
type: text
|
||||||
|
- column:
|
||||||
|
name: contraindications
|
||||||
|
type: text
|
||||||
|
- column:
|
||||||
|
name: country
|
||||||
|
type: varchar(100)
|
||||||
|
- column:
|
||||||
|
name: image_url
|
||||||
|
type: varchar(1024)
|
||||||
|
- column:
|
||||||
|
name: source_url
|
||||||
|
type: varchar(1024)
|
||||||
|
- column:
|
||||||
|
name: rating
|
||||||
|
type: decimal(3,2)
|
||||||
|
- column:
|
||||||
|
name: price_range
|
||||||
|
type: varchar(50)
|
||||||
|
- column:
|
||||||
|
name: created_at
|
||||||
|
type: timestamp with time zone
|
||||||
|
defaultValueComputed: now()
|
||||||
|
- column:
|
||||||
|
name: updated_at
|
||||||
|
type: timestamp with time zone
|
||||||
|
defaultValueComputed: now()
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_supplements_category
|
||||||
|
tableName: supplements
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: category
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_supplements_brand
|
||||||
|
tableName: supplements
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: brand
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_supplements_source_url
|
||||||
|
tableName: supplements
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: source_url
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 002-create-ingredients-table
|
||||||
|
author: backend-agent
|
||||||
|
preConditions:
|
||||||
|
- onFail: MARK_RAN
|
||||||
|
- not:
|
||||||
|
- tableExists:
|
||||||
|
tableName: ingredients
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: ingredients
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: uuid
|
||||||
|
defaultValueComputed: gen_random_uuid()
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: name
|
||||||
|
type: varchar(255)
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: amount
|
||||||
|
type: decimal(10,4)
|
||||||
|
- column:
|
||||||
|
name: unit
|
||||||
|
type: varchar(50)
|
||||||
|
- column:
|
||||||
|
name: daily_value_percent
|
||||||
|
type: int
|
||||||
|
- column:
|
||||||
|
name: supplement_id
|
||||||
|
type: uuid
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
foreignKeyName: fk_ingredients_supplement
|
||||||
|
references: supplements(id)
|
||||||
|
deleteCascade: true
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_ingredients_supplement_id
|
||||||
|
tableName: ingredients
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: supplement_id
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_ingredients_name
|
||||||
|
tableName: ingredients
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: name
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
databaseChangeLog:
|
||||||
|
- changeSet:
|
||||||
|
id: 003-create-collector-runs-table
|
||||||
|
author: backend-agent
|
||||||
|
changes:
|
||||||
|
- createTable:
|
||||||
|
tableName: collector_runs
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: id
|
||||||
|
type: uuid
|
||||||
|
defaultValueComputed: gen_random_uuid()
|
||||||
|
constraints:
|
||||||
|
primaryKey: true
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: run_at
|
||||||
|
type: timestamp with time zone
|
||||||
|
constraints:
|
||||||
|
nullable: false
|
||||||
|
- column:
|
||||||
|
name: source
|
||||||
|
type: varchar(500)
|
||||||
|
- column:
|
||||||
|
name: added
|
||||||
|
type: int
|
||||||
|
- column:
|
||||||
|
name: updated
|
||||||
|
type: int
|
||||||
|
- column:
|
||||||
|
name: errors
|
||||||
|
type: int
|
||||||
|
- column:
|
||||||
|
name: status
|
||||||
|
type: varchar(50)
|
||||||
|
- createIndex:
|
||||||
|
indexName: idx_collector_runs_run_at
|
||||||
|
tableName: collector_runs
|
||||||
|
columns:
|
||||||
|
- column:
|
||||||
|
name: run_at
|
||||||
|
descending: true
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
databaseChangeLog:
|
||||||
|
- include:
|
||||||
|
file: db/changelog/V001_create_supplements_table.yaml
|
||||||
|
- include:
|
||||||
|
file: db/changelog/V002_create_ingredients_table.yaml
|
||||||
|
- include:
|
||||||
|
file: db/changelog/V003_create_collector_runs_table.yaml
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package ru.oa2.mvp.nutricollector;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
class NutriCollectorApplicationTests {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("nutri")
|
||||||
|
.withUsername("nutri")
|
||||||
|
.withPassword("nutri");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", postgres::getUsername);
|
||||||
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.service.CollectorService;
|
||||||
|
import ru.oa2.mvp.nutricollector.service.SupplementMapper;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(CollectorController.class)
|
||||||
|
class CollectorControllerTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private CollectorService collectorService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private SupplementMapper mapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUploadCsvSuccessfully() throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "supplements.csv", "text/csv",
|
||||||
|
"name,brand\nVitamin C,Nature Made".getBytes());
|
||||||
|
|
||||||
|
CollectorRun run = CollectorRun.builder()
|
||||||
|
.runAt(Instant.now())
|
||||||
|
.source("upload:supplements.csv")
|
||||||
|
.added(1).updated(0).errors(0)
|
||||||
|
.status("SUCCESS")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
UUID runId = UUID.randomUUID();
|
||||||
|
CollectorRunResponse response = new CollectorRunResponse(
|
||||||
|
runId, run.getRunAt(), "upload:supplements.csv", 1, 0, 0, "SUCCESS");
|
||||||
|
|
||||||
|
when(collectorService.processCsvStream(any(InputStream.class), eq("upload:supplements.csv")))
|
||||||
|
.thenReturn(run);
|
||||||
|
when(mapper.toRunResponse(run)).thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/v1/collector/upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("SUCCESS"))
|
||||||
|
.andExpect(jsonPath("$.added").value(1))
|
||||||
|
.andExpect(jsonPath("$.errors").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnRecentRuns() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
CollectorRun run = CollectorRun.builder()
|
||||||
|
.id(id).runAt(now).source("test").added(5).updated(2).errors(0).status("SUCCESS")
|
||||||
|
.build();
|
||||||
|
CollectorRunResponse response = new CollectorRunResponse(id, now, "test", 5, 2, 0, "SUCCESS");
|
||||||
|
|
||||||
|
when(collectorService.getRecentRuns()).thenReturn(List.of(run));
|
||||||
|
when(mapper.toRunResponse(run)).thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v1/collector/runs"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].source").value("test"))
|
||||||
|
.andExpect(jsonPath("$[0].added").value(5))
|
||||||
|
.andExpect(jsonPath("$[0].status").value("SUCCESS"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Supplement;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.CollectorRunRepository;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.IngredientRepository;
|
||||||
|
import ru.oa2.mvp.nutricollector.repository.SupplementRepository;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CollectorServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SupplementRepository supplementRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IngredientRepository ingredientRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CollectorRunRepository collectorRunRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private SupplementMapper mapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IngredientParser ingredientParser;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private CollectorService collectorService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldProcessCsvAndCreateNewSupplements() {
|
||||||
|
String csv = "name,brand,category,source_url,ingredients\n" +
|
||||||
|
"Vitamin C,Nature Made,Vitamins,https://example.com/vc,Vitamin C - 500 mg\n";
|
||||||
|
|
||||||
|
InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
Supplement newSupplement = Supplement.builder().name("Vitamin C").build();
|
||||||
|
when(mapper.fromCsvRecord(any())).thenReturn(newSupplement);
|
||||||
|
when(supplementRepository.findBySourceUrl("https://example.com/vc")).thenReturn(Optional.empty());
|
||||||
|
when(supplementRepository.save(any(Supplement.class))).thenReturn(newSupplement);
|
||||||
|
when(ingredientParser.parse(any())).thenReturn(List.of());
|
||||||
|
when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
CollectorRun result = collectorService.processCsvStream(is, "test");
|
||||||
|
|
||||||
|
assertThat(result.getStatus()).isEqualTo("SUCCESS");
|
||||||
|
assertThat(result.getAdded()).isEqualTo(1);
|
||||||
|
assertThat(result.getUpdated()).isEqualTo(0);
|
||||||
|
assertThat(result.getErrors()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdateExistingSupplement() {
|
||||||
|
String csv = "name,brand,category,source_url,ingredients\n" +
|
||||||
|
"Vitamin C,Nature Made,Vitamins,https://example.com/vc,\n";
|
||||||
|
|
||||||
|
InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
Supplement existing = Supplement.builder().name("Vitamin C").build();
|
||||||
|
when(supplementRepository.findBySourceUrl("https://example.com/vc")).thenReturn(Optional.of(existing));
|
||||||
|
when(supplementRepository.save(any(Supplement.class))).thenReturn(existing);
|
||||||
|
when(ingredientParser.parse(any())).thenReturn(List.of());
|
||||||
|
when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
CollectorRun result = collectorService.processCsvStream(is, "test");
|
||||||
|
|
||||||
|
assertThat(result.getStatus()).isEqualTo("SUCCESS");
|
||||||
|
assertThat(result.getAdded()).isEqualTo(0);
|
||||||
|
assertThat(result.getUpdated()).isEqualTo(1);
|
||||||
|
verify(mapper).updateFromCsvRecord(eq(existing), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSkipRecordWithEmptyName() {
|
||||||
|
String csv = "name,brand,category,source_url\n" +
|
||||||
|
",Nature Made,Vitamins,https://example.com/vc\n";
|
||||||
|
|
||||||
|
InputStream is = new ByteArrayInputStream(csv.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
when(collectorRunRepository.save(any(CollectorRun.class))).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
CollectorRun result = collectorService.processCsvStream(is, "test");
|
||||||
|
|
||||||
|
assertThat(result.getErrors()).isEqualTo(1);
|
||||||
|
assertThat(result.getAdded()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnRecentRuns() {
|
||||||
|
CollectorRun run = CollectorRun.builder().source("test").status("SUCCESS").build();
|
||||||
|
when(collectorRunRepository.findTop10ByOrderByRunAtDesc()).thenReturn(List.of(run));
|
||||||
|
|
||||||
|
List<CollectorRun> result = collectorService.getRecentRuns();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().getSource()).isEqualTo("test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.IngredientData;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class IngredientParserTest {
|
||||||
|
|
||||||
|
private IngredientParser parser;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
parser = new IngredientParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldParseSimpleIngredientWithAmountAndUnit() {
|
||||||
|
List<IngredientData> result = parser.parse("Vitamin C - 500 mg");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().name()).isEqualTo("Vitamin C");
|
||||||
|
assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("500"));
|
||||||
|
assertThat(result.getFirst().unit()).isEqualTo("mg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldParseMultipleIngredientsSeparatedBySemicolon() {
|
||||||
|
List<IngredientData> result = parser.parse("Vitamin C - 500 mg; Zinc - 15 mg; Vitamin D - 25 mcg");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
assertThat(result.get(0).name()).isEqualTo("Vitamin C");
|
||||||
|
assertThat(result.get(1).name()).isEqualTo("Zinc");
|
||||||
|
assertThat(result.get(2).name()).isEqualTo("Vitamin D");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldParseIngredientWithDailyValue() {
|
||||||
|
List<IngredientData> result = parser.parse("Vitamin C - 90 mg (100% DV)");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().name()).isEqualTo("Vitamin C");
|
||||||
|
assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("90"));
|
||||||
|
assertThat(result.getFirst().unit()).isEqualTo("mg");
|
||||||
|
assertThat(result.getFirst().dailyValuePercent()).isEqualTo(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldParseIngredientWithoutAmount() {
|
||||||
|
List<IngredientData> result = parser.parse("Proprietary Blend");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().name()).isEqualTo("Proprietary Blend");
|
||||||
|
assertThat(result.getFirst().amount()).isNull();
|
||||||
|
assertThat(result.getFirst().unit()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListForNullInput() {
|
||||||
|
assertThat(parser.parse(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListForBlankInput() {
|
||||||
|
assertThat(parser.parse(" ")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandlePipeSeparator() {
|
||||||
|
List<IngredientData> result = parser.parse("Vitamin A - 900 mcg | Vitamin E - 15 mg");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result.get(0).name()).isEqualTo("Vitamin A");
|
||||||
|
assertThat(result.get(1).name()).isEqualTo("Vitamin E");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleDecimalAmounts() {
|
||||||
|
List<IngredientData> result = parser.parse("Vitamin B12 - 2,4 mcg");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().amount()).isEqualByComparingTo(new BigDecimal("2.4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
package ru.oa2.mvp.nutricollector.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.CollectorRunResponse;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.IngredientData;
|
||||||
|
import ru.oa2.mvp.nutricollector.dto.SupplementCsvRecord;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.CollectorRun;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Ingredient;
|
||||||
|
import ru.oa2.mvp.nutricollector.entity.Supplement;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SupplementMapperTest {
|
||||||
|
|
||||||
|
private SupplementMapper mapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mapper = new SupplementMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldMapCsvRecordToSupplement() {
|
||||||
|
SupplementCsvRecord csv = new SupplementCsvRecord();
|
||||||
|
csv.setName("Omega-3 Fish Oil");
|
||||||
|
csv.setBrand("Nordic Naturals");
|
||||||
|
csv.setCategory("Fish Oil");
|
||||||
|
csv.setForm("Softgel");
|
||||||
|
csv.setServingSize("2 softgels");
|
||||||
|
csv.setServingsPerContainer("60");
|
||||||
|
csv.setRating("4.5");
|
||||||
|
csv.setCountry("Norway");
|
||||||
|
|
||||||
|
Supplement result = mapper.fromCsvRecord(csv);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Omega-3 Fish Oil");
|
||||||
|
assertThat(result.getBrand()).isEqualTo("Nordic Naturals");
|
||||||
|
assertThat(result.getCategory()).isEqualTo("Fish Oil");
|
||||||
|
assertThat(result.getForm()).isEqualTo("Softgel");
|
||||||
|
assertThat(result.getServingSize()).isEqualTo("2 softgels");
|
||||||
|
assertThat(result.getServingsPerContainer()).isEqualTo(60);
|
||||||
|
assertThat(result.getRating()).isEqualByComparingTo(new BigDecimal("4.5"));
|
||||||
|
assertThat(result.getCountry()).isEqualTo("Norway");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdateExistingSupplementFromCsvRecord() {
|
||||||
|
Supplement existing = Supplement.builder()
|
||||||
|
.name("Old Name")
|
||||||
|
.brand("Old Brand")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
SupplementCsvRecord csv = new SupplementCsvRecord();
|
||||||
|
csv.setName("New Name");
|
||||||
|
csv.setBrand("New Brand");
|
||||||
|
csv.setCategory("Vitamins");
|
||||||
|
|
||||||
|
mapper.updateFromCsvRecord(existing, csv);
|
||||||
|
|
||||||
|
assertThat(existing.getName()).isEqualTo("New Name");
|
||||||
|
assertThat(existing.getBrand()).isEqualTo("New Brand");
|
||||||
|
assertThat(existing.getCategory()).isEqualTo("Vitamins");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldMapIngredientData() {
|
||||||
|
IngredientData data = new IngredientData("Vitamin C", new BigDecimal("500"), "mg", 555);
|
||||||
|
|
||||||
|
Ingredient result = mapper.fromIngredientData(data);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Vitamin C");
|
||||||
|
assertThat(result.getAmount()).isEqualByComparingTo(new BigDecimal("500"));
|
||||||
|
assertThat(result.getUnit()).isEqualTo("mg");
|
||||||
|
assertThat(result.getDailyValuePercent()).isEqualTo(555);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldMapCollectorRunToResponse() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
CollectorRun run = CollectorRun.builder()
|
||||||
|
.id(id)
|
||||||
|
.runAt(now)
|
||||||
|
.source("test-source")
|
||||||
|
.added(10)
|
||||||
|
.updated(5)
|
||||||
|
.errors(1)
|
||||||
|
.status("COMPLETED_WITH_ERRORS")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
CollectorRunResponse response = mapper.toRunResponse(run);
|
||||||
|
|
||||||
|
assertThat(response.id()).isEqualTo(id);
|
||||||
|
assertThat(response.runAt()).isEqualTo(now);
|
||||||
|
assertThat(response.source()).isEqualTo("test-source");
|
||||||
|
assertThat(response.added()).isEqualTo(10);
|
||||||
|
assertThat(response.updated()).isEqualTo(5);
|
||||||
|
assertThat(response.errors()).isEqualTo(1);
|
||||||
|
assertThat(response.status()).isEqualTo("COMPLETED_WITH_ERRORS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleInvalidNumericValues() {
|
||||||
|
SupplementCsvRecord csv = new SupplementCsvRecord();
|
||||||
|
csv.setName("Test Supplement");
|
||||||
|
csv.setServingsPerContainer("not-a-number");
|
||||||
|
csv.setRating("invalid");
|
||||||
|
|
||||||
|
Supplement result = mapper.fromCsvRecord(csv);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Test Supplement");
|
||||||
|
assertThat(result.getServingsPerContainer()).isNull();
|
||||||
|
assertThat(result.getRating()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue