Initial MUH app implementation

This commit is contained in:
2026-03-12 11:43:27 +01:00
commit fb8e3c8ef6
69 changed files with 8387 additions and 0 deletions

59
backend/pom.xml Normal file
View File

@@ -0,0 +1,59 @@
<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.2.5</version>
<relativePath/>
</parent>
<groupId>de.svencarstensen</groupId>
<artifactId>muh-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>muh-backend</name>
<description>MUH application backend</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MuhApplication {
public static void main(String[] args) {
SpringApplication.run(MuhApplication.class, args);
}
}

View File

@@ -0,0 +1,27 @@
package de.svencarstensen.muh.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
CorsFilter corsFilter(@Value("${muh.cors.allowed-origins}") List<String> allowedOrigins) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,9 @@
package de.svencarstensen.muh.domain;
public record AntibiogramEntry(
String antibioticBusinessKey,
String antibioticCode,
String antibioticName,
SensitivityResult result
) {
}

View File

@@ -0,0 +1,19 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("antibiotics")
public record AntibioticCatalogItem(
@Id String id,
String businessKey,
String code,
String name,
boolean active,
String supersedesId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,23 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("users")
public record AppUser(
@Id String id,
String code,
String displayName,
String companyName,
String address,
String email,
String portalLogin,
String passwordHash,
boolean active,
UserRole role,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,19 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("farmers")
public record Farmer(
@Id String id,
String businessKey,
String name,
String email,
boolean active,
String supersedesId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,19 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("medications")
public record MedicationCatalogItem(
@Id String id,
String businessKey,
String name,
MedicationCategory category,
boolean active,
String supersedesId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,9 @@
package de.svencarstensen.muh.domain;
public enum MedicationCategory {
IN_UDDER,
SYSTEMIC_ANTIBIOTIC,
SYSTEMIC_PAIN,
DRY_SEALER,
DRY_ANTIBIOTIC
}

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
@Document("pathogens")
public record PathogenCatalogItem(
@Id String id,
String businessKey,
String code,
String name,
PathogenKind kind,
boolean active,
String supersedesId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,8 @@
package de.svencarstensen.muh.domain;
public enum PathogenKind {
BACTERIAL,
NO_GROWTH,
CONTAMINATED,
OTHER
}

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh.domain;
import java.util.List;
public record QuarterAntibiogram(
QuarterKey quarterKey,
String pathogenBusinessKey,
String pathogenName,
QuarterKey inheritedFromQuarter,
List<AntibiogramEntry> entries
) {
}

View File

@@ -0,0 +1,24 @@
package de.svencarstensen.muh.domain;
public record QuarterFinding(
QuarterKey quarterKey,
boolean flagged,
String pathogenBusinessKey,
String pathogenCode,
String pathogenName,
PathogenKind pathogenKind,
String customPathogenName,
Integer cellCount
) {
public boolean requiresAntibiogram() {
return pathogenKind == PathogenKind.BACTERIAL;
}
public String effectivePathogenLabel() {
if (customPathogenName != null && !customPathogenName.isBlank()) {
return customPathogenName.trim();
}
return pathogenName;
}
}

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.domain;
public enum QuarterKey {
SINGLE("Einzelprobe"),
UNKNOWN("Unbekannt"),
LEFT_FRONT("Vorne links"),
RIGHT_FRONT("Vorne rechts"),
LEFT_REAR("Hinten links"),
RIGHT_REAR("Hinten rechts");
private final String label;
QuarterKey(String label) {
this.label = label;
}
public String label() {
return label;
}
}

View File

@@ -0,0 +1,33 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("samples")
public record Sample(
@Id String id,
long sampleNumber,
String farmerBusinessKey,
String farmerName,
String farmerEmail,
String cowNumber,
String cowName,
SampleKind sampleKind,
SamplingMode samplingMode,
SampleWorkflowStep currentStep,
List<QuarterFinding> quarters,
List<QuarterAntibiogram> antibiograms,
TherapyRecommendation therapyRecommendation,
boolean reportSent,
boolean reportBlocked,
LocalDateTime reportSentAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime completedAt,
String createdByUserCode,
String createdByDisplayName
) {
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.domain;
public enum SampleKind {
LACTATION,
DRY_OFF
}

View File

@@ -0,0 +1,8 @@
package de.svencarstensen.muh.domain;
public enum SampleWorkflowStep {
ANAMNESIS,
ANTIBIOGRAM,
THERAPY,
COMPLETED
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum SamplingMode {
SINGLE_SITE,
FOUR_QUARTER,
UNKNOWN_SITE
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum SensitivityResult {
SENSITIVE,
INTERMEDIATE,
RESISTANT
}

View File

@@ -0,0 +1,21 @@
package de.svencarstensen.muh.domain;
import java.util.List;
public record TherapyRecommendation(
boolean continueStarted,
boolean switchTherapy,
List<String> inUdderMedicationKeys,
List<String> inUdderMedicationNames,
String inUdderOther,
List<String> systemicMedicationKeys,
List<String> systemicMedicationNames,
String systemicOther,
List<String> drySealerKeys,
List<String> drySealerNames,
List<String> dryAntibioticKeys,
List<String> dryAntibioticNames,
String farmerNote,
String internalNote
) {
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.domain;
public enum UserRole {
APP,
ADMIN,
CUSTOMER
}

View File

@@ -0,0 +1,10 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.AntibioticCatalogItem;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface AntibioticCatalogRepository extends MongoRepository<AntibioticCatalogItem, String> {
List<AntibioticCatalogItem> findByActiveTrueOrderByNameAsc();
}

View File

@@ -0,0 +1,17 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.AppUser;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
import java.util.Optional;
public interface AppUserRepository extends MongoRepository<AppUser, String> {
List<AppUser> findByActiveTrueOrderByDisplayNameAsc();
Optional<AppUser> findByCodeIgnoreCase(String code);
Optional<AppUser> findByEmailIgnoreCase(String email);
Optional<AppUser> findByPortalLoginIgnoreCase(String portalLogin);
}

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.Farmer;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface FarmerRepository extends MongoRepository<Farmer, String> {
List<Farmer> findByActiveTrueOrderByNameAsc();
List<Farmer> findByNameContainingIgnoreCaseOrderByNameAsc(String name);
}

View File

@@ -0,0 +1,10 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.MedicationCatalogItem;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface MedicationCatalogRepository extends MongoRepository<MedicationCatalogItem, String> {
List<MedicationCatalogItem> findByActiveTrueOrderByNameAsc();
}

View File

@@ -0,0 +1,10 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface PathogenCatalogRepository extends MongoRepository<PathogenCatalogItem, String> {
List<PathogenCatalogItem> findByActiveTrueOrderByNameAsc();
}

View File

@@ -0,0 +1,22 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.Sample;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface SampleRepository extends MongoRepository<Sample, String> {
Optional<Sample> findBySampleNumber(long sampleNumber);
Optional<Sample> findTopByOrderBySampleNumberDesc();
List<Sample> findTop12ByOrderByUpdatedAtDesc();
List<Sample> findByFarmerBusinessKeyOrderByCreatedAtDesc(String farmerBusinessKey);
List<Sample> findByCompletedAtBetweenOrderByCompletedAtDesc(LocalDateTime start, LocalDateTime end);
List<Sample> findByCompletedAtNotNullOrderByCompletedAtDesc();
}

View File

@@ -0,0 +1,906 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibioticCatalogItem;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.Farmer;
import de.svencarstensen.muh.domain.MedicationCatalogItem;
import de.svencarstensen.muh.domain.MedicationCategory;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.UserRole;
import de.svencarstensen.muh.repository.AntibioticCatalogRepository;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class CatalogService {
private static final Comparator<FarmerRow> FARMER_ROW_COMPARATOR = Comparator
.comparing(FarmerRow::active).reversed()
.thenComparing(FarmerRow::name, String.CASE_INSENSITIVE_ORDER)
.thenComparing(FarmerRow::updatedAt, Comparator.nullsLast(Comparator.reverseOrder()));
private static final Comparator<MedicationRow> MEDICATION_ROW_COMPARATOR = Comparator
.comparing(MedicationRow::active).reversed()
.thenComparing(MedicationRow::category)
.thenComparing(MedicationRow::name, String.CASE_INSENSITIVE_ORDER);
private static final Comparator<PathogenRow> PATHOGEN_ROW_COMPARATOR = Comparator
.comparing(PathogenRow::active).reversed()
.thenComparing(PathogenRow::name, String.CASE_INSENSITIVE_ORDER)
.thenComparing(PathogenRow::code, String.CASE_INSENSITIVE_ORDER);
private static final Comparator<AntibioticRow> ANTIBIOTIC_ROW_COMPARATOR = Comparator
.comparing(AntibioticRow::active).reversed()
.thenComparing(AntibioticRow::name, String.CASE_INSENSITIVE_ORDER)
.thenComparing(AntibioticRow::code, String.CASE_INSENSITIVE_ORDER);
private final FarmerRepository farmerRepository;
private final MedicationCatalogRepository medicationRepository;
private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository;
private final AppUserRepository appUserRepository;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public CatalogService(
FarmerRepository farmerRepository,
MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository,
AppUserRepository appUserRepository
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository;
this.appUserRepository = appUserRepository;
}
public ActiveCatalogSummary activeCatalogSummary() {
return new ActiveCatalogSummary(
farmerRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toFarmerOption).toList(),
medicationRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toMedicationOption).toList(),
pathogenRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toPathogenOption).toList(),
antibioticRepository.findByActiveTrueOrderByNameAsc().stream().map(this::toAntibioticOption).toList(),
activeQuickLoginUsers().stream().map(this::toUserOption).toList()
);
}
public AdministrationOverview administrationOverview() {
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
}
public List<FarmerRow> listFarmerRows() {
return farmerRepository.findAll().stream()
.map(this::toFarmerRow)
.sorted(FARMER_ROW_COMPARATOR)
.toList();
}
public List<MedicationRow> listMedicationRows() {
return medicationRepository.findAll().stream()
.map(this::toMedicationRow)
.sorted(MEDICATION_ROW_COMPARATOR)
.toList();
}
public List<PathogenRow> listPathogenRows() {
return pathogenRepository.findAll().stream()
.map(this::toPathogenRow)
.sorted(PATHOGEN_ROW_COMPARATOR)
.toList();
}
public List<AntibioticRow> listAntibioticRows() {
return antibioticRepository.findAll().stream()
.map(this::toAntibioticRow)
.sorted(ANTIBIOTIC_ROW_COMPARATOR)
.toList();
}
public List<FarmerRow> saveFarmers(List<FarmerMutation> mutations) {
for (FarmerMutation mutation : mutations) {
if (isBlank(mutation.name())) {
continue;
}
LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) {
farmerRepository.save(new Farmer(
null,
UUID.randomUUID().toString(),
mutation.name().trim(),
blankToNull(mutation.email()),
mutation.active(),
null,
now,
now
));
continue;
}
String mutationId = requireText(mutation.id(), "Landwirt-ID fehlt");
Farmer existing = farmerRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Landwirt nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.email(), blankToNull(mutation.email()));
if (changed) {
farmerRepository.save(new Farmer(
existing.id(),
existing.businessKey(),
existing.name(),
existing.email(),
false,
existing.supersedesId(),
existing.createdAt(),
now
));
farmerRepository.save(new Farmer(
null,
existing.businessKey(),
mutation.name().trim(),
blankToNull(mutation.email()),
mutation.active(),
existing.id(),
now,
now
));
continue;
}
if (existing.active() != mutation.active()) {
farmerRepository.save(new Farmer(
existing.id(),
existing.businessKey(),
existing.name(),
existing.email(),
mutation.active(),
existing.supersedesId(),
existing.createdAt(),
now
));
}
}
return listFarmerRows();
}
public List<MedicationRow> saveMedications(List<MedicationMutation> mutations) {
for (MedicationMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.category() == null) {
continue;
}
LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) {
medicationRepository.save(new MedicationCatalogItem(
null,
UUID.randomUUID().toString(),
mutation.name().trim(),
mutation.category(),
mutation.active(),
null,
now,
now
));
continue;
}
String mutationId = requireText(mutation.id(), "Medikament-ID fehlt");
MedicationCatalogItem existing = medicationRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Medikament nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim())
|| existing.category() != mutation.category();
if (changed) {
medicationRepository.save(new MedicationCatalogItem(
existing.id(),
existing.businessKey(),
existing.name(),
existing.category(),
false,
existing.supersedesId(),
existing.createdAt(),
now
));
medicationRepository.save(new MedicationCatalogItem(
null,
existing.businessKey(),
mutation.name().trim(),
mutation.category(),
mutation.active(),
existing.id(),
now,
now
));
continue;
}
if (existing.active() != mutation.active()) {
medicationRepository.save(new MedicationCatalogItem(
existing.id(),
existing.businessKey(),
existing.name(),
existing.category(),
mutation.active(),
existing.supersedesId(),
existing.createdAt(),
now
));
}
}
return listMedicationRows();
}
public List<PathogenRow> savePathogens(List<PathogenMutation> mutations) {
for (PathogenMutation mutation : mutations) {
if (isBlank(mutation.name()) || mutation.kind() == null) {
continue;
}
LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) {
pathogenRepository.save(new PathogenCatalogItem(
null,
UUID.randomUUID().toString(),
blankToNull(mutation.code()),
mutation.name().trim(),
mutation.kind(),
mutation.active(),
null,
now,
now
));
continue;
}
String mutationId = requireText(mutation.id(), "Erreger-ID fehlt");
PathogenCatalogItem existing = pathogenRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Erreger nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code()))
|| existing.kind() != mutation.kind();
if (changed) {
pathogenRepository.save(new PathogenCatalogItem(
existing.id(),
existing.businessKey(),
existing.code(),
existing.name(),
existing.kind(),
false,
existing.supersedesId(),
existing.createdAt(),
now
));
pathogenRepository.save(new PathogenCatalogItem(
null,
existing.businessKey(),
blankToNull(mutation.code()),
mutation.name().trim(),
mutation.kind(),
mutation.active(),
existing.id(),
now,
now
));
continue;
}
if (existing.active() != mutation.active()) {
pathogenRepository.save(new PathogenCatalogItem(
existing.id(),
existing.businessKey(),
existing.code(),
existing.name(),
existing.kind(),
mutation.active(),
existing.supersedesId(),
existing.createdAt(),
now
));
}
}
return listPathogenRows();
}
public List<AntibioticRow> saveAntibiotics(List<AntibioticMutation> mutations) {
for (AntibioticMutation mutation : mutations) {
if (isBlank(mutation.name())) {
continue;
}
LocalDateTime now = LocalDateTime.now();
if (isBlank(mutation.id())) {
antibioticRepository.save(new AntibioticCatalogItem(
null,
UUID.randomUUID().toString(),
blankToNull(mutation.code()),
mutation.name().trim(),
mutation.active(),
null,
now,
now
));
continue;
}
String mutationId = requireText(mutation.id(), "Antibiotika-ID fehlt");
AntibioticCatalogItem existing = antibioticRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Antibiotikum nicht gefunden"));
boolean changed = !existing.name().equals(mutation.name().trim())
|| !safeEquals(existing.code(), blankToNull(mutation.code()));
if (changed) {
antibioticRepository.save(new AntibioticCatalogItem(
existing.id(),
existing.businessKey(),
existing.code(),
existing.name(),
false,
existing.supersedesId(),
existing.createdAt(),
now
));
antibioticRepository.save(new AntibioticCatalogItem(
null,
existing.businessKey(),
blankToNull(mutation.code()),
mutation.name().trim(),
mutation.active(),
existing.id(),
now,
now
));
continue;
}
if (existing.active() != mutation.active()) {
antibioticRepository.save(new AntibioticCatalogItem(
existing.id(),
existing.businessKey(),
existing.code(),
existing.name(),
mutation.active(),
existing.supersedesId(),
existing.createdAt(),
now
));
}
}
return listAntibioticRows();
}
public List<UserRow> listUsers() {
ensureDefaultUsers();
return appUserRepository.findAll().stream()
.map(this::toUserRow)
.sorted(Comparator.comparing(UserRow::active).reversed().thenComparing(UserRow::displayName, String.CASE_INSENSITIVE_ORDER))
.toList();
}
public UserRow createOrUpdateUser(UserMutation mutation) {
if (isBlank(mutation.displayName()) || isBlank(mutation.code())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername und Kürzel sind erforderlich");
}
LocalDateTime now = LocalDateTime.now();
validateUserMutation(mutation);
if (isBlank(mutation.id())) {
AppUser created = appUserRepository.save(new AppUser(
null,
mutation.code().trim().toUpperCase(),
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
normalizeEmail(mutation.email()),
blankToNull(mutation.portalLogin()),
encodeIfPresent(mutation.password()),
mutation.active(),
Optional.ofNullable(mutation.role()).orElse(UserRole.APP),
now,
now
));
return toUserRow(created);
}
String mutationId = requireText(mutation.id(), "Benutzer-ID fehlt");
AppUser existing = appUserRepository.findById(mutationId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
AppUser saved = appUserRepository.save(new AppUser(
existing.id(),
mutation.code().trim().toUpperCase(),
mutation.displayName().trim(),
blankToNull(mutation.companyName()),
blankToNull(mutation.address()),
normalizeEmail(mutation.email()),
blankToNull(mutation.portalLogin()),
isBlank(mutation.password()) ? existing.passwordHash() : passwordEncoder.encode(mutation.password()),
mutation.active(),
Optional.ofNullable(mutation.role()).orElse(existing.role()),
existing.createdAt(),
now
));
return toUserRow(saved);
}
public void deleteUser(String id) {
appUserRepository.deleteById(requireText(id, "Benutzer-ID fehlt"));
}
public void changePassword(String id, String newPassword) {
if (isBlank(newPassword)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwort darf nicht leer sein");
}
AppUser existing = appUserRepository.findById(requireText(id, "Benutzer-ID fehlt"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzer nicht gefunden"));
appUserRepository.save(new AppUser(
existing.id(),
existing.code(),
existing.displayName(),
existing.companyName(),
existing.address(),
existing.email(),
existing.portalLogin(),
passwordEncoder.encode(newPassword),
existing.active(),
existing.role(),
existing.createdAt(),
LocalDateTime.now()
));
}
public UserOption loginByCode(String code) {
AppUser user = activeQuickLoginUsers().stream()
.filter(candidate -> candidate.code().equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Benutzerkürzel unbekannt"));
return toUserOption(user);
}
public UserOption loginWithPassword(String identifier, String password) {
if (isBlank(identifier) || isBlank(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Benutzername/E-Mail und Passwort sind erforderlich");
}
AppUser user = resolvePasswordUser(identifier.trim())
.filter(AppUser::active)
.filter(candidate -> candidate.passwordHash() != null && passwordEncoder.matches(password, candidate.passwordHash()))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Anmeldung fehlgeschlagen"));
return toUserOption(user);
}
public UserOption registerCustomer(RegistrationMutation mutation) {
if (isBlank(mutation.companyName()) || isBlank(mutation.address()) || isBlank(mutation.email()) || isBlank(mutation.password())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Firmenname, Adresse, E-Mail und Passwort sind erforderlich");
}
String normalizedEmail = normalizeEmail(mutation.email());
if (appUserRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits registriert");
}
String companyName = mutation.companyName().trim();
String address = mutation.address().trim();
String displayName = companyName;
String portalLogin = generateUniquePortalLogin(localPart(normalizedEmail));
String code = generateUniqueCode("K" + companyName);
LocalDateTime now = LocalDateTime.now();
AppUser created = appUserRepository.save(new AppUser(
null,
code,
displayName,
companyName,
address,
normalizedEmail,
portalLogin,
passwordEncoder.encode(mutation.password()),
true,
UserRole.CUSTOMER,
now,
now
));
return toUserOption(created);
}
private List<AppUser> activeUsers() {
ensureDefaultUsers();
return appUserRepository.findByActiveTrueOrderByDisplayNameAsc();
}
private List<AppUser> activeQuickLoginUsers() {
return activeUsers().stream()
.filter(user -> user.role() != UserRole.CUSTOMER)
.toList();
}
public void ensureDefaultUsers() {
ensureDefaultUser("ADM", "Administrator", "admin@muh.local", "admin", "Admin123!", UserRole.ADMIN);
ensureDefaultUser("SV", "Sven", "sven@muh.local", "sven", "muh123", UserRole.APP);
ensureDefaultUser("AK", "Anna", "anna@muh.local", "anna", "muh123", UserRole.APP);
ensureDefaultUser("LH", "Lena", "lena@muh.local", "lena", "muh123", UserRole.APP);
}
public Farmer requireActiveFarmer(String businessKey) {
return farmerRepository.findByActiveTrueOrderByNameAsc().stream()
.filter(farmer -> farmer.businessKey().equals(businessKey))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
}
public Map<String, PathogenCatalogItem> activePathogensByBusinessKey() {
return pathogenRepository.findByActiveTrueOrderByNameAsc().stream()
.collect(Collectors.toMap(PathogenCatalogItem::businessKey, Function.identity()));
}
public Map<String, AntibioticCatalogItem> activeAntibioticsByBusinessKey() {
return antibioticRepository.findByActiveTrueOrderByNameAsc().stream()
.collect(Collectors.toMap(AntibioticCatalogItem::businessKey, Function.identity()));
}
public Map<String, MedicationCatalogItem> activeMedicationsByBusinessKey() {
return medicationRepository.findByActiveTrueOrderByNameAsc().stream()
.collect(Collectors.toMap(MedicationCatalogItem::businessKey, Function.identity()));
}
private FarmerRow toFarmerRow(Farmer farmer) {
return new FarmerRow(
farmer.id(),
farmer.businessKey(),
farmer.name(),
farmer.email(),
farmer.active(),
farmer.updatedAt()
);
}
private MedicationRow toMedicationRow(MedicationCatalogItem item) {
return new MedicationRow(
item.id(),
item.businessKey(),
item.name(),
item.category(),
item.active(),
item.updatedAt()
);
}
private PathogenRow toPathogenRow(PathogenCatalogItem item) {
return new PathogenRow(
item.id(),
item.businessKey(),
item.code(),
item.name(),
item.kind(),
item.active(),
item.updatedAt()
);
}
private AntibioticRow toAntibioticRow(AntibioticCatalogItem item) {
return new AntibioticRow(
item.id(),
item.businessKey(),
item.code(),
item.name(),
item.active(),
item.updatedAt()
);
}
private UserRow toUserRow(AppUser user) {
return new UserRow(
user.id(),
user.code(),
user.displayName(),
user.companyName(),
user.address(),
user.email(),
user.portalLogin(),
user.active(),
user.role(),
user.updatedAt()
);
}
private FarmerOption toFarmerOption(Farmer farmer) {
return new FarmerOption(farmer.businessKey(), farmer.name(), farmer.email());
}
private MedicationOption toMedicationOption(MedicationCatalogItem item) {
return new MedicationOption(item.businessKey(), item.name(), item.category());
}
private PathogenOption toPathogenOption(PathogenCatalogItem item) {
return new PathogenOption(item.businessKey(), item.code(), item.name(), item.kind());
}
private AntibioticOption toAntibioticOption(AntibioticCatalogItem item) {
return new AntibioticOption(item.businessKey(), item.code(), item.name());
}
private UserOption toUserOption(AppUser user) {
return new UserOption(
user.id(),
user.code(),
user.displayName(),
user.companyName(),
user.address(),
user.email(),
user.portalLogin(),
user.role()
);
}
private String encodeIfPresent(String password) {
return isBlank(password) ? null : passwordEncoder.encode(password);
}
private @NonNull String requireText(String value, String message) {
if (isBlank(value)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
String sanitized = Objects.requireNonNull(value).trim();
return Objects.requireNonNull(sanitized);
}
private void validateUserMutation(UserMutation mutation) {
String normalizedEmail = normalizeEmail(mutation.email());
String normalizedLogin = blankToNull(mutation.portalLogin());
String normalizedCode = mutation.code().trim().toUpperCase(Locale.ROOT);
appUserRepository.findAll().forEach(existing -> {
if (!safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.code() != null
&& existing.code().equalsIgnoreCase(normalizedCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieses Kürzel ist bereits vergeben");
}
if (normalizedEmail != null
&& existing.email() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.email().equalsIgnoreCase(normalizedEmail)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Diese E-Mail-Adresse ist bereits vergeben");
}
if (normalizedLogin != null
&& existing.portalLogin() != null
&& !safeEquals(existing.id(), blankToNull(mutation.id()))
&& existing.portalLogin().equalsIgnoreCase(normalizedLogin)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Dieser Benutzername ist bereits vergeben");
}
});
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private boolean safeEquals(String left, String right) {
return left == null ? right == null : left.equals(right);
}
private Optional<AppUser> resolvePasswordUser(String identifier) {
return appUserRepository.findByEmailIgnoreCase(identifier)
.or(() -> appUserRepository.findByPortalLoginIgnoreCase(identifier));
}
private void ensureDefaultUser(
String code,
String displayName,
String email,
String portalLogin,
String rawPassword,
UserRole role
) {
boolean exists = appUserRepository.findByCodeIgnoreCase(code).isPresent()
|| appUserRepository.findByEmailIgnoreCase(email).isPresent()
|| appUserRepository.findByPortalLoginIgnoreCase(portalLogin).isPresent();
if (exists) {
return;
}
LocalDateTime now = LocalDateTime.now();
appUserRepository.save(new AppUser(
null,
code,
displayName,
null,
null,
email,
portalLogin,
passwordEncoder.encode(rawPassword),
true,
role,
now,
now
));
}
private String normalizeEmail(String email) {
return isBlank(email) ? null : email.trim().toLowerCase(Locale.ROOT);
}
private String localPart(String email) {
int separator = email.indexOf('@');
return separator >= 0 ? email.substring(0, separator) : email;
}
private String generateUniquePortalLogin(String seed) {
String base = seed.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]", "");
if (base.isBlank()) {
base = "user";
}
String candidate = base;
int index = 2;
while (appUserRepository.findByPortalLoginIgnoreCase(candidate).isPresent()) {
candidate = base + index++;
}
return candidate;
}
private String generateUniqueCode(String seed) {
String compact = seed.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
if (compact.isBlank()) {
compact = "USR";
}
String base = compact.length() >= 3 ? compact.substring(0, Math.min(4, compact.length())) : (compact + "XXX").substring(0, 3);
Set<String> usedCodes = appUserRepository.findAll().stream()
.map(AppUser::code)
.filter(code -> code != null && !code.isBlank())
.map(code -> code.toUpperCase(Locale.ROOT))
.collect(Collectors.toCollection(HashSet::new));
String candidate = base.length() > 3 ? base.substring(0, 3) : base;
if (!usedCodes.contains(candidate)) {
return candidate;
}
String prefix = candidate.substring(0, Math.min(2, candidate.length()));
int index = 1;
while (true) {
String numbered = (prefix + index).toUpperCase(Locale.ROOT);
if (!usedCodes.contains(numbered)) {
return numbered;
}
index++;
}
}
public record ActiveCatalogSummary(
List<FarmerOption> farmers,
List<MedicationOption> medications,
List<PathogenOption> pathogens,
List<AntibioticOption> antibiotics,
List<UserOption> users
) {
}
public record AdministrationOverview(
List<FarmerRow> farmers,
List<MedicationRow> medications,
List<PathogenRow> pathogens,
List<AntibioticRow> antibiotics
) {
}
public record FarmerOption(String businessKey, String name, String email) {
}
public record MedicationOption(String businessKey, String name, MedicationCategory category) {
}
public record PathogenOption(String businessKey, String code, String name, PathogenKind kind) {
}
public record AntibioticOption(String businessKey, String code, String name) {
}
public record UserOption(
String id,
String code,
String displayName,
String companyName,
String address,
String email,
String portalLogin,
UserRole role
) {
}
public record FarmerRow(
String id,
String businessKey,
String name,
String email,
boolean active,
LocalDateTime updatedAt
) {
}
public record FarmerMutation(String id, String name, String email, boolean active) {
}
public record MedicationRow(
String id,
String businessKey,
String name,
MedicationCategory category,
boolean active,
LocalDateTime updatedAt
) {
}
public record MedicationMutation(String id, String name, MedicationCategory category, boolean active) {
}
public record PathogenRow(
String id,
String businessKey,
String code,
String name,
PathogenKind kind,
boolean active,
LocalDateTime updatedAt
) {
}
public record PathogenMutation(String id, String code, String name, PathogenKind kind, boolean active) {
}
public record AntibioticRow(
String id,
String businessKey,
String code,
String name,
boolean active,
LocalDateTime updatedAt
) {
}
public record AntibioticMutation(String id, String code, String name, boolean active) {
}
public record UserRow(
String id,
String code,
String displayName,
String companyName,
String address,
String email,
String portalLogin,
boolean active,
UserRole role,
LocalDateTime updatedAt
) {
}
public record UserMutation(
String id,
String code,
String displayName,
String companyName,
String address,
String email,
String portalLogin,
String password,
boolean active,
UserRole role
) {
}
public record RegistrationMutation(
String companyName,
String address,
String email,
String password
) {
}
}

View File

@@ -0,0 +1,76 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibioticCatalogItem;
import de.svencarstensen.muh.domain.Farmer;
import de.svencarstensen.muh.domain.MedicationCatalogItem;
import de.svencarstensen.muh.domain.MedicationCategory;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.repository.AntibioticCatalogRepository;
import de.svencarstensen.muh.repository.FarmerRepository;
import de.svencarstensen.muh.repository.MedicationCatalogRepository;
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
public class DemoDataInitializer implements ApplicationRunner {
private final FarmerRepository farmerRepository;
private final MedicationCatalogRepository medicationRepository;
private final PathogenCatalogRepository pathogenRepository;
private final AntibioticCatalogRepository antibioticRepository;
private final CatalogService catalogService;
public DemoDataInitializer(
FarmerRepository farmerRepository,
MedicationCatalogRepository medicationRepository,
PathogenCatalogRepository pathogenRepository,
AntibioticCatalogRepository antibioticRepository,
CatalogService catalogService
) {
this.farmerRepository = farmerRepository;
this.medicationRepository = medicationRepository;
this.pathogenRepository = pathogenRepository;
this.antibioticRepository = antibioticRepository;
this.catalogService = catalogService;
}
@Override
public void run(ApplicationArguments args) {
LocalDateTime now = LocalDateTime.now();
if (farmerRepository.count() == 0) {
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Hof Hansen", "hansen@example.com", true, null, now, now));
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Agrar Lindenblick", "lindenblick@example.com", true, null, now, now));
farmerRepository.save(new Farmer(null, UUID.randomUUID().toString(), "Gut Westerkamp", "westerkamp@example.com", true, null, now, now));
}
if (medicationRepository.count() == 0) {
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Mastijet", MedicationCategory.IN_UDDER, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Metacam", MedicationCategory.SYSTEMIC_PAIN, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Cobactan", MedicationCategory.SYSTEMIC_ANTIBIOTIC, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Orbeseal", MedicationCategory.DRY_SEALER, true, null, now, now));
medicationRepository.save(new MedicationCatalogItem(null, UUID.randomUUID().toString(), "Nafpenzal", MedicationCategory.DRY_ANTIBIOTIC, true, null, now, now));
}
if (pathogenRepository.count() == 0) {
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "SAU", "Staph. aureus", PathogenKind.BACTERIAL, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "ECO", "E. coli", PathogenKind.BACTERIAL, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "NG", "Kein Wachstum", PathogenKind.NO_GROWTH, true, null, now, now));
pathogenRepository.save(new PathogenCatalogItem(null, UUID.randomUUID().toString(), "VER", "Verunreinigt", PathogenKind.CONTAMINATED, true, null, now, now));
}
if (antibioticRepository.count() == 0) {
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "PEN", "Penicillin", true, null, now, now));
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "CEF", "Cefalexin", true, null, now, now));
antibioticRepository.save(new AntibioticCatalogItem(null, UUID.randomUUID().toString(), "ENR", "Enrofloxacin", true, null, now, now));
}
catalogService.ensureDefaultUsers();
}
}

View File

@@ -0,0 +1,127 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.Sample;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@Service
public class PortalService {
private final SampleService sampleService;
private final ReportService reportService;
private final CatalogService catalogService;
public PortalService(SampleService sampleService, ReportService reportService, CatalogService catalogService) {
this.sampleService = sampleService;
this.reportService = reportService;
this.catalogService = catalogService;
}
public PortalSnapshot snapshot(String farmerBusinessKey, String farmerQuery, String cowQuery, Long sampleNumber, LocalDate date) {
List<CatalogService.FarmerOption> matchingFarmers = catalogService.activeCatalogSummary().farmers().stream()
.filter(farmer -> farmerQuery == null || farmerQuery.isBlank() || farmer.name().toLowerCase(Locale.ROOT).contains(farmerQuery.toLowerCase(Locale.ROOT)))
.toList();
List<PortalSampleRow> sampleRows;
if (sampleNumber != null) {
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(sampleNumber)));
} else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) {
sampleRows = sampleService.samplesByFarmerBusinessKey(farmerBusinessKey).stream()
.filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery))
.map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
.toList();
} else if (date != null) {
sampleRows = sampleService.samplesByDate(date).stream()
.map(this::toPortalRow)
.sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.toList();
} else {
sampleRows = sampleService.completedSamples().stream()
.limit(25)
.map(this::toPortalRow)
.toList();
}
return new PortalSnapshot(
matchingFarmers,
sampleRows,
reportService.reportCandidates(),
catalogService.listUsers()
);
}
private boolean cowMatches(Sample sample, String cowQuery) {
String query = cowQuery.toLowerCase(Locale.ROOT);
return (sample.cowNumber() != null && sample.cowNumber().toLowerCase(Locale.ROOT).contains(query))
|| (sample.cowName() != null && sample.cowName().toLowerCase(Locale.ROOT).contains(query));
}
private PortalSampleRow toPortalRow(Sample sample) {
return new PortalSampleRow(
sample.id(),
sample.sampleNumber(),
sample.createdAt(),
sample.completedAt(),
sample.farmerBusinessKey(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowNumber(),
sample.cowName(),
sample.sampleKind().name(),
sample.therapyRecommendation() == null ? null : sample.therapyRecommendation().internalNote(),
sample.currentStep() == de.svencarstensen.muh.domain.SampleWorkflowStep.COMPLETED,
sample.reportSent(),
sample.reportBlocked()
);
}
private PortalSampleRow toPortalRow(SampleService.SampleDetail sample) {
return new PortalSampleRow(
sample.id(),
sample.sampleNumber(),
sample.createdAt(),
sample.completedAt(),
sample.farmerBusinessKey(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowNumber(),
sample.cowName(),
sample.sampleKind().name(),
sample.therapy() == null ? null : sample.therapy().internalNote(),
sample.completed(),
sample.reportSent(),
sample.reportBlocked()
);
}
public record PortalSnapshot(
List<CatalogService.FarmerOption> farmers,
List<PortalSampleRow> samples,
List<ReportService.ReportCandidate> reportCandidates,
List<CatalogService.UserRow> users
) {
}
public record PortalSampleRow(
String sampleId,
long sampleNumber,
java.time.LocalDateTime createdAt,
java.time.LocalDateTime completedAt,
String farmerBusinessKey,
String farmerName,
String farmerEmail,
String cowNumber,
String cowName,
String sampleKindLabel,
String internalNote,
boolean completed,
boolean reportSent,
boolean reportBlocked
) {
}
}

View File

@@ -0,0 +1,210 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.Sample;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import jakarta.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Service
public class ReportService {
private final SampleService sampleService;
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final boolean mailEnabled;
private final String mailFrom;
public ReportService(
SampleService sampleService,
ObjectProvider<JavaMailSender> mailSenderProvider,
@Value("${muh.mail.enabled:false}") boolean mailEnabled,
@Value("${muh.mail.from:no-reply@muh.local}") String mailFrom
) {
this.sampleService = sampleService;
this.mailSenderProvider = mailSenderProvider;
this.mailEnabled = mailEnabled;
this.mailFrom = mailFrom;
}
public List<ReportCandidate> reportCandidates() {
return sampleService.completedSamples().stream()
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
.map(this::toCandidate)
.toList();
}
public DispatchResult sendReports(List<String> sampleIds) {
List<ReportCandidate> sent = new ArrayList<>();
List<ReportCandidate> skipped = new ArrayList<>();
for (String sampleId : sampleIds) {
Sample sample = sampleService.loadSampleEntity(sampleId);
if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) {
skipped.add(toCandidate(sample));
continue;
}
byte[] pdf = buildPdf(sample);
if (mailEnabled && mailSenderProvider.getIfAvailable() != null) {
sendMail(sample, pdf);
}
Sample updated = sampleService.markReportSent(sample.id(), LocalDateTime.now());
sent.add(toCandidate(updated));
}
return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null);
}
public byte[] reportPdf(String sampleId) {
return buildPdf(sampleService.loadSampleEntity(sampleId));
}
public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) {
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id());
}
private void sendMail(Sample sample, byte[] pdf) {
try {
JavaMailSender sender = mailSenderProvider.getIfAvailable();
if (sender == null) {
return;
}
MimeMessage message = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setFrom(requireText(mailFrom, "Absender fehlt"));
helper.setTo(requireText(sample.farmerEmail(), "Empfänger fehlt"));
helper.setSubject("MUH-Bericht Probe " + sample.sampleNumber());
helper.setText("Im Anhang befindet sich der Bericht zur Probe " + sample.sampleNumber() + ".", false);
helper.addAttachment("MUH-Bericht-" + sample.sampleNumber() + ".pdf", new ByteArrayResource(Objects.requireNonNull(pdf)));
sender.send(message);
} catch (Exception exception) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Mailversand fehlgeschlagen", exception);
}
}
private ReportCandidate toCandidate(Sample sample) {
return new ReportCandidate(
sample.id(),
sample.sampleNumber(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowName() == null ? sample.cowNumber() : sample.cowNumber() + " / " + sample.cowName(),
sample.completedAt(),
sample.reportSent(),
sample.reportBlocked()
);
}
private byte[] buildPdf(Sample sample) {
List<String> lines = new ArrayList<>();
lines.add("MUH-Bericht");
lines.add("Probe: " + sample.sampleNumber());
lines.add("Landwirt: " + sample.farmerName());
lines.add("Kuh: " + sample.cowNumber() + (sample.cowName() == null ? "" : " / " + sample.cowName()));
lines.add("Typ: " + sample.sampleKind());
lines.add("Abgeschlossen: " + (sample.completedAt() == null ? "-" : sample.completedAt().format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))));
lines.add("");
lines.add("Anamnese");
sample.quarters().forEach(quarter -> lines.add(
quarter.quarterKey().label() + ": "
+ (quarter.effectivePathogenLabel() == null ? "-" : quarter.effectivePathogenLabel())
+ (quarter.flagged() ? " [auffaellig]" : "")
));
lines.add("");
lines.add("Therapie");
if (sample.therapyRecommendation() != null) {
lines.add("Hinweis Landwirt: " + defaultText(sample.therapyRecommendation().farmerNote()));
lines.add("Interne Bemerkung: " + defaultText(sample.therapyRecommendation().internalNote()));
}
return renderSimplePdf(lines);
}
private String defaultText(String value) {
return value == null || value.isBlank() ? "-" : value;
}
private @NonNull String requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
return Objects.requireNonNull(value);
}
private byte[] renderSimplePdf(List<String> lines) {
StringBuilder content = new StringBuilder();
content.append("BT\n/F1 12 Tf\n50 790 Td\n");
boolean first = true;
for (String line : lines) {
if (!first) {
content.append("0 -18 Td\n");
}
content.append("(").append(escapePdf(line)).append(") Tj\n");
first = false;
}
content.append("ET");
String stream = content.toString();
List<String> objects = List.of(
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Length " + stream.getBytes(StandardCharsets.UTF_8).length + " >>\nstream\n" + stream + "\nendstream"
);
StringBuilder pdf = new StringBuilder("%PDF-1.4\n");
List<Integer> offsets = new ArrayList<>();
for (int index = 0; index < objects.size(); index++) {
offsets.add(pdf.toString().getBytes(StandardCharsets.UTF_8).length);
pdf.append(index + 1).append(" 0 obj\n").append(objects.get(index)).append("\nendobj\n");
}
int xrefOffset = pdf.toString().getBytes(StandardCharsets.UTF_8).length;
pdf.append("xref\n0 ").append(objects.size() + 1).append("\n");
pdf.append("0000000000 65535 f \n");
for (Integer offset : offsets) {
pdf.append(String.format("%010d 00000 n %n", offset));
}
pdf.append("trailer\n<< /Size ").append(objects.size() + 1).append(" /Root 1 0 R >>\n");
pdf.append("startxref\n").append(xrefOffset).append("\n%%EOF");
return pdf.toString().getBytes(StandardCharsets.UTF_8);
}
private String escapePdf(String value) {
return value
.replace("\\", "\\\\")
.replace("(", "\\(")
.replace(")", "\\)");
}
public record ReportCandidate(
String sampleId,
long sampleNumber,
String farmerName,
String farmerEmail,
String cowLabel,
LocalDateTime completedAt,
boolean reportSent,
boolean reportBlocked
) {
}
public record DispatchResult(
List<ReportCandidate> sent,
List<ReportCandidate> skipped,
boolean mailDeliveryActive
) {
}
}

View File

@@ -0,0 +1,769 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AntibiogramEntry;
import de.svencarstensen.muh.domain.PathogenCatalogItem;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterAntibiogram;
import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.SampleKind;
import de.svencarstensen.muh.domain.SampleWorkflowStep;
import de.svencarstensen.muh.domain.SamplingMode;
import de.svencarstensen.muh.domain.SensitivityResult;
import de.svencarstensen.muh.domain.TherapyRecommendation;
import de.svencarstensen.muh.repository.SampleRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
public class SampleService {
private final SampleRepository sampleRepository;
private final CatalogService catalogService;
public SampleService(SampleRepository sampleRepository, CatalogService catalogService) {
this.sampleRepository = sampleRepository;
this.catalogService = catalogService;
}
public DashboardOverview dashboardOverview() {
List<SampleSummary> recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream()
.map(this::toSummary)
.toList();
long openCount = sampleRepository.findAll().stream().filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED).count();
LocalDate today = LocalDate.now();
long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(
today.atStartOfDay(),
today.plusDays(1).atStartOfDay()
).size();
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent);
}
public LookupResult lookup(long sampleNumber) {
return sampleRepository.findBySampleNumber(sampleNumber)
.map(sample -> new LookupResult(
true,
"Probe gefunden",
sample.id(),
sample.currentStep(),
SampleWorkflowRules.routeSegment(sample.currentStep())
))
.orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null));
}
public SampleDetail getSample(String id) {
return toDetail(loadSample(id));
}
public SampleDetail getSampleByNumber(long sampleNumber) {
return sampleRepository.findBySampleNumber(sampleNumber)
.map(this::toDetail)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
}
public SampleDetail createSample(RegistrationRequest request) {
LocalDateTime now = LocalDateTime.now();
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
Sample sample = new Sample(
null,
nextSampleNumber(),
farmer.businessKey(),
farmer.name(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
request.sampleKind(),
request.samplingMode(),
SampleWorkflowStep.ANAMNESIS,
buildQuarters(request),
List.of(),
null,
false,
false,
null,
now,
now,
null,
request.userCode(),
request.userDisplayName()
);
return toDetail(sampleRepository.save(sample));
}
public SampleDetail saveRegistration(String id, RegistrationRequest request) {
Sample existing = loadSample(id);
if (!SampleWorkflowRules.canEditRegistration(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
}
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Landwirt nicht gefunden"));
Sample saved = sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
farmer.businessKey(),
farmer.name(),
farmer.email(),
request.cowNumber().trim(),
blankToNull(request.cowName()),
request.sampleKind(),
request.samplingMode(),
SampleWorkflowStep.ANAMNESIS,
buildQuarters(request),
List.of(),
existing.therapyRecommendation(),
existing.reportSent(),
existing.reportBlocked(),
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) {
Sample existing = loadSample(id);
if (!SampleWorkflowRules.canEditAnamnesis(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden");
}
Map<QuarterKey, QuarterFinding> current = new HashMap<>();
for (QuarterFinding quarter : existing.quarters()) {
current.put(quarter.quarterKey(), quarter);
}
Map<String, PathogenCatalogItem> pathogens = catalogService.activePathogensByBusinessKey();
List<QuarterFinding> updatedQuarters = new ArrayList<>();
for (AnamnesisQuarterRequest quarterRequest : request.quarters()) {
QuarterFinding base = current.get(quarterRequest.quarterKey());
if (base == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Entnahmestelle unbekannt");
}
PathogenCatalogItem catalogItem = quarterRequest.pathogenBusinessKey() == null
? null
: pathogens.get(quarterRequest.pathogenBusinessKey());
String customPathogen = blankToNull(quarterRequest.customPathogenName());
PathogenKind pathogenKind = catalogItem != null ? catalogItem.kind() : (customPathogen == null ? null : PathogenKind.OTHER);
updatedQuarters.add(new QuarterFinding(
base.quarterKey(),
base.flagged(),
catalogItem != null ? catalogItem.businessKey() : null,
catalogItem != null ? catalogItem.code() : null,
catalogItem != null ? catalogItem.name() : null,
pathogenKind,
customPathogen,
quarterRequest.cellCount()
));
}
updatedQuarters.sort(Comparator.comparingInt(this::quarterSort));
SampleWorkflowStep nextStep = SampleWorkflowRules.nextStepAfterAnamnesis(updatedQuarters);
Sample saved = sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
nextStep,
updatedQuarters,
List.of(),
existing.therapyRecommendation(),
existing.reportSent(),
existing.reportBlocked(),
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) {
Sample existing = loadSample(id);
if (!SampleWorkflowRules.canEditAntibiogram(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
}
Map<String, de.svencarstensen.muh.domain.AntibioticCatalogItem> antibiotics = catalogService.activeAntibioticsByBusinessKey();
Map<QuarterKey, QuarterAntibiogram> groups = new HashMap<>();
Map<QuarterKey, QuarterFinding> quartersByKey = existing.quarters().stream()
.collect(java.util.stream.Collectors.toMap(QuarterFinding::quarterKey, quarter -> quarter));
for (AntibiogramGroupRequest groupRequest : request.groups()) {
QuarterFinding referenceQuarter = quartersByKey.get(groupRequest.referenceQuarter());
if (referenceQuarter == null || !referenceQuarter.requiresAntibiogram()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Ungültige Referenz für Antibiogramm");
}
List<AntibiogramEntry> entries = groupRequest.entries().stream()
.map(entry -> {
de.svencarstensen.muh.domain.AntibioticCatalogItem catalogItem = antibiotics.get(entry.antibioticBusinessKey());
if (catalogItem == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiotikum unbekannt");
}
return new AntibiogramEntry(
catalogItem.businessKey(),
catalogItem.code(),
catalogItem.name(),
entry.result()
);
})
.toList();
for (QuarterFinding quarter : existing.quarters()) {
if (!quarter.requiresAntibiogram()) {
continue;
}
if (Objects.equals(
SampleWorkflowRules.pathogenIdentity(referenceQuarter),
SampleWorkflowRules.pathogenIdentity(quarter)
)) {
groups.put(quarter.quarterKey(), new QuarterAntibiogram(
quarter.quarterKey(),
quarter.pathogenBusinessKey(),
quarter.effectivePathogenLabel(),
referenceQuarter.quarterKey().equals(quarter.quarterKey()) ? null : referenceQuarter.quarterKey(),
entries
));
}
}
}
List<QuarterAntibiogram> savedGroups = existing.quarters().stream()
.filter(QuarterFinding::requiresAntibiogram)
.map(quarter -> groups.getOrDefault(quarter.quarterKey(), new QuarterAntibiogram(
quarter.quarterKey(),
quarter.pathogenBusinessKey(),
quarter.effectivePathogenLabel(),
SampleWorkflowRules.referenceQuarterForPathogen(existing.quarters(), quarter).equals(quarter.quarterKey())
? null
: SampleWorkflowRules.referenceQuarterForPathogen(existing.quarters(), quarter),
List.of()
)))
.toList();
Sample saved = sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
SampleWorkflowStep.THERAPY,
existing.quarters(),
savedGroups,
existing.therapyRecommendation(),
existing.reportSent(),
existing.reportBlocked(),
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public SampleDetail saveTherapy(String id, TherapyRequest request) {
Sample existing = loadSample(id);
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
TherapyRecommendation previous = existing.therapyRecommendation();
TherapyRecommendation updated = previous == null
? new TherapyRecommendation(false, false, List.of(), List.of(), null, List.of(), List.of(), null, List.of(), List.of(), List.of(), List.of(), null, blankToNull(request.internalNote()))
: new TherapyRecommendation(
previous.continueStarted(),
previous.switchTherapy(),
previous.inUdderMedicationKeys(),
previous.inUdderMedicationNames(),
previous.inUdderOther(),
previous.systemicMedicationKeys(),
previous.systemicMedicationNames(),
previous.systemicOther(),
previous.drySealerKeys(),
previous.drySealerNames(),
previous.dryAntibioticKeys(),
previous.dryAntibioticNames(),
previous.farmerNote(),
blankToNull(request.internalNote())
);
return toDetail(sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
SampleWorkflowStep.COMPLETED,
existing.quarters(),
existing.antibiograms(),
updated,
existing.reportSent(),
existing.reportBlocked(),
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
)));
}
if (!SampleWorkflowRules.canEditTherapy(existing)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Therapie kann nicht bearbeitet werden");
}
Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications = catalogService.activeMedicationsByBusinessKey();
TherapyRecommendation therapy = new TherapyRecommendation(
request.continueStarted(),
request.switchTherapy(),
request.inUdderMedicationKeys(),
resolveMedicationNames(request.inUdderMedicationKeys(), medications),
blankToNull(request.inUdderOther()),
request.systemicMedicationKeys(),
resolveMedicationNames(request.systemicMedicationKeys(), medications),
blankToNull(request.systemicOther()),
request.drySealerKeys(),
resolveMedicationNames(request.drySealerKeys(), medications),
request.dryAntibioticKeys(),
resolveMedicationNames(request.dryAntibioticKeys(), medications),
blankToNull(request.farmerNote()),
blankToNull(request.internalNote())
);
Sample saved = sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
SampleWorkflowStep.COMPLETED,
existing.quarters(),
existing.antibiograms(),
therapy,
existing.reportSent(),
existing.reportBlocked(),
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
LocalDateTime.now(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
return toDetail(saved);
}
public Sample markReportSent(String id, LocalDateTime sentAt) {
Sample existing = loadSample(id);
return sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
existing.currentStep(),
existing.quarters(),
existing.antibiograms(),
existing.therapyRecommendation(),
true,
existing.reportBlocked(),
sentAt,
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
}
public Sample toggleReportBlocked(String id, boolean blocked) {
Sample existing = loadSample(id);
return sampleRepository.save(new Sample(
existing.id(),
existing.sampleNumber(),
existing.farmerBusinessKey(),
existing.farmerName(),
existing.farmerEmail(),
existing.cowNumber(),
existing.cowName(),
existing.sampleKind(),
existing.samplingMode(),
existing.currentStep(),
existing.quarters(),
existing.antibiograms(),
existing.therapyRecommendation(),
existing.reportSent(),
blocked,
existing.reportSentAt(),
existing.createdAt(),
LocalDateTime.now(),
existing.completedAt(),
existing.createdByUserCode(),
existing.createdByDisplayName()
));
}
public List<Sample> completedSamples() {
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc();
}
public List<Sample> samplesByFarmerBusinessKey(String businessKey) {
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
}
public List<Sample> samplesByDate(LocalDate date) {
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
}
public long nextSampleNumber() {
return sampleRepository.findTopByOrderBySampleNumberDesc()
.map(sample -> sample.sampleNumber() + 1)
.orElse(100001L);
}
public Sample loadSampleEntity(String id) {
return loadSample(id);
}
private Sample loadSample(String id) {
return sampleRepository.findById(Objects.requireNonNull(id))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
}
private SampleSummary toSummary(Sample sample) {
return new SampleSummary(
sample.id(),
sample.sampleNumber(),
sample.farmerName(),
sample.cowName() == null ? sample.cowNumber() : sample.cowNumber() + " / " + sample.cowName(),
sample.sampleKind(),
sample.currentStep(),
sample.updatedAt(),
sample.reportSent(),
sample.reportBlocked()
);
}
private SampleDetail toDetail(Sample sample) {
List<QuarterView> quarters = sample.quarters().stream()
.map(quarter -> new QuarterView(
quarter.quarterKey(),
quarter.quarterKey().label(),
quarter.flagged(),
quarter.pathogenBusinessKey(),
quarter.pathogenCode(),
quarter.pathogenName(),
quarter.pathogenKind(),
quarter.customPathogenName(),
quarter.cellCount(),
quarter.requiresAntibiogram()
))
.sorted(Comparator.comparingInt(quarter -> quarterSort(quarter.quarterKey())))
.toList();
List<AntibiogramView> antibiograms = sample.antibiograms().stream()
.map(group -> new AntibiogramView(
group.quarterKey(),
group.pathogenName(),
group.inheritedFromQuarter(),
group.entries().stream()
.map(entry -> new AntibiogramEntryView(entry.antibioticBusinessKey(), entry.antibioticCode(), entry.antibioticName(), entry.result()))
.toList()
))
.toList();
return new SampleDetail(
sample.id(),
sample.sampleNumber(),
sample.farmerBusinessKey(),
sample.farmerName(),
sample.farmerEmail(),
sample.cowNumber(),
sample.cowName(),
sample.sampleKind(),
sample.samplingMode(),
sample.currentStep(),
sample.createdAt(),
sample.updatedAt(),
sample.completedAt(),
sample.createdByUserCode(),
sample.createdByDisplayName(),
sample.reportSent(),
sample.reportBlocked(),
sample.reportSentAt(),
SampleWorkflowRules.routeSegment(sample.currentStep()),
quarters,
antibiograms,
toTherapyView(sample.therapyRecommendation()),
SampleWorkflowRules.antibiogramTargets(sample.quarters()),
SampleWorkflowRules.canEditRegistration(sample),
SampleWorkflowRules.canEditAnamnesis(sample),
SampleWorkflowRules.canEditAntibiogram(sample),
SampleWorkflowRules.canEditTherapy(sample),
sample.currentStep() == SampleWorkflowStep.COMPLETED
);
}
private TherapyView toTherapyView(TherapyRecommendation therapy) {
if (therapy == null) {
return null;
}
return new TherapyView(
therapy.continueStarted(),
therapy.switchTherapy(),
therapy.inUdderMedicationKeys(),
therapy.inUdderMedicationNames(),
therapy.inUdderOther(),
therapy.systemicMedicationKeys(),
therapy.systemicMedicationNames(),
therapy.systemicOther(),
therapy.drySealerKeys(),
therapy.drySealerNames(),
therapy.dryAntibioticKeys(),
therapy.dryAntibioticNames(),
therapy.farmerNote(),
therapy.internalNote()
);
}
private List<String> resolveMedicationNames(List<String> keys, Map<String, de.svencarstensen.muh.domain.MedicationCatalogItem> medications) {
return keys == null ? List.of() : keys.stream()
.map(medications::get)
.filter(Objects::nonNull)
.map(de.svencarstensen.muh.domain.MedicationCatalogItem::name)
.toList();
}
private List<QuarterFinding> buildQuarters(RegistrationRequest request) {
return switch (request.samplingMode()) {
case SINGLE_SITE -> List.of(new QuarterFinding(QuarterKey.SINGLE, false, null, null, null, null, null, null));
case UNKNOWN_SITE -> List.of(new QuarterFinding(QuarterKey.UNKNOWN, false, null, null, null, null, null, null));
case FOUR_QUARTER -> List.of(
new QuarterFinding(QuarterKey.LEFT_FRONT, request.flaggedQuarters().contains(QuarterKey.LEFT_FRONT), null, null, null, null, null, null),
new QuarterFinding(QuarterKey.RIGHT_FRONT, request.flaggedQuarters().contains(QuarterKey.RIGHT_FRONT), null, null, null, null, null, null),
new QuarterFinding(QuarterKey.LEFT_REAR, request.flaggedQuarters().contains(QuarterKey.LEFT_REAR), null, null, null, null, null, null),
new QuarterFinding(QuarterKey.RIGHT_REAR, request.flaggedQuarters().contains(QuarterKey.RIGHT_REAR), null, null, null, null, null, null)
);
};
}
private int quarterSort(QuarterFinding finding) {
return quarterSort(finding.quarterKey());
}
private int quarterSort(QuarterKey key) {
return switch (key) {
case SINGLE -> 0;
case UNKNOWN -> 1;
case LEFT_FRONT -> 2;
case RIGHT_FRONT -> 3;
case LEFT_REAR -> 4;
case RIGHT_REAR -> 5;
};
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
public record DashboardOverview(
long nextSampleNumber,
long openSamples,
long completedToday,
List<SampleSummary> recentSamples
) {
}
public record SampleSummary(
String id,
long sampleNumber,
String farmerName,
String cowLabel,
SampleKind sampleKind,
SampleWorkflowStep currentStep,
LocalDateTime updatedAt,
boolean reportSent,
boolean reportBlocked
) {
}
public record LookupResult(
boolean found,
String message,
String sampleId,
SampleWorkflowStep step,
String routeSegment
) {
}
public record QuarterView(
QuarterKey quarterKey,
String label,
boolean flagged,
String pathogenBusinessKey,
String pathogenCode,
String pathogenName,
PathogenKind pathogenKind,
String customPathogenName,
Integer cellCount,
boolean requiresAntibiogram
) {
}
public record AntibiogramEntryView(
String antibioticBusinessKey,
String antibioticCode,
String antibioticName,
SensitivityResult result
) {
}
public record AntibiogramView(
QuarterKey quarterKey,
String pathogenName,
QuarterKey inheritedFromQuarter,
List<AntibiogramEntryView> entries
) {
}
public record TherapyView(
boolean continueStarted,
boolean switchTherapy,
List<String> inUdderMedicationKeys,
List<String> inUdderMedicationNames,
String inUdderOther,
List<String> systemicMedicationKeys,
List<String> systemicMedicationNames,
String systemicOther,
List<String> drySealerKeys,
List<String> drySealerNames,
List<String> dryAntibioticKeys,
List<String> dryAntibioticNames,
String farmerNote,
String internalNote
) {
}
public record SampleDetail(
String id,
long sampleNumber,
String farmerBusinessKey,
String farmerName,
String farmerEmail,
String cowNumber,
String cowName,
SampleKind sampleKind,
SamplingMode samplingMode,
SampleWorkflowStep currentStep,
LocalDateTime createdAt,
LocalDateTime updatedAt,
LocalDateTime completedAt,
String createdByUserCode,
String createdByDisplayName,
boolean reportSent,
boolean reportBlocked,
LocalDateTime reportSentAt,
String routeSegment,
List<QuarterView> quarters,
List<AntibiogramView> antibiograms,
TherapyView therapy,
List<QuarterKey> antibiogramTargets,
boolean registrationEditable,
boolean anamnesisEditable,
boolean antibiogramEditable,
boolean therapyEditable,
boolean completed
) {
}
public record RegistrationRequest(
String farmerBusinessKey,
String cowNumber,
String cowName,
SampleKind sampleKind,
SamplingMode samplingMode,
List<QuarterKey> flaggedQuarters,
String userCode,
String userDisplayName
) {
}
public record AnamnesisQuarterRequest(
QuarterKey quarterKey,
String pathogenBusinessKey,
String customPathogenName,
Integer cellCount
) {
}
public record AnamnesisRequest(List<AnamnesisQuarterRequest> quarters) {
}
public record AntibiogramLineRequest(String antibioticBusinessKey, SensitivityResult result) {
}
public record AntibiogramGroupRequest(QuarterKey referenceQuarter, List<AntibiogramLineRequest> entries) {
}
public record AntibiogramRequest(List<AntibiogramGroupRequest> groups) {
}
public record TherapyRequest(
boolean continueStarted,
boolean switchTherapy,
List<String> inUdderMedicationKeys,
String inUdderOther,
List<String> systemicMedicationKeys,
String systemicOther,
List<String> drySealerKeys,
List<String> dryAntibioticKeys,
String farmerNote,
String internalNote
) {
}
}

View File

@@ -0,0 +1,85 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey;
import de.svencarstensen.muh.domain.Sample;
import de.svencarstensen.muh.domain.SampleWorkflowStep;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class SampleWorkflowRules {
private SampleWorkflowRules() {
}
public static String routeSegment(SampleWorkflowStep step) {
return switch (step) {
case ANAMNESIS -> "anamnesis";
case ANTIBIOGRAM -> "antibiogram";
case THERAPY, COMPLETED -> "therapy";
};
}
public static SampleWorkflowStep nextStepAfterAnamnesis(List<QuarterFinding> quarters) {
return antibiogramTargets(quarters).isEmpty()
? SampleWorkflowStep.THERAPY
: SampleWorkflowStep.ANTIBIOGRAM;
}
public static boolean canEditRegistration(Sample sample) {
return sample.currentStep() == SampleWorkflowStep.ANAMNESIS;
}
public static boolean canEditAnamnesis(Sample sample) {
return sample.currentStep() == SampleWorkflowStep.ANAMNESIS
|| sample.currentStep() == SampleWorkflowStep.ANTIBIOGRAM;
}
public static boolean canEditAntibiogram(Sample sample) {
return sample.currentStep() == SampleWorkflowStep.ANTIBIOGRAM
|| sample.currentStep() == SampleWorkflowStep.THERAPY;
}
public static boolean canEditTherapy(Sample sample) {
return sample.currentStep() == SampleWorkflowStep.THERAPY;
}
public static List<QuarterKey> antibiogramTargets(List<QuarterFinding> quarters) {
Map<String, QuarterKey> firstByPathogen = new HashMap<>();
List<QuarterKey> targets = new ArrayList<>();
for (QuarterFinding quarter : quarters) {
if (quarter.pathogenKind() != PathogenKind.BACTERIAL) {
continue;
}
String identity = pathogenIdentity(quarter);
if (firstByPathogen.putIfAbsent(identity, quarter.quarterKey()) == null) {
targets.add(quarter.quarterKey());
}
}
return targets;
}
public static QuarterKey referenceQuarterForPathogen(List<QuarterFinding> quarters, QuarterFinding candidate) {
String identity = pathogenIdentity(candidate);
for (QuarterFinding quarter : quarters) {
if (quarter.pathogenKind() == PathogenKind.BACTERIAL
&& Objects.equals(identity, pathogenIdentity(quarter))) {
return quarter.quarterKey();
}
}
return candidate.quarterKey();
}
public static String pathogenIdentity(QuarterFinding quarter) {
if (quarter.pathogenBusinessKey() != null && !quarter.pathogenBusinessKey().isBlank()) {
return quarter.pathogenBusinessKey();
}
return quarter.effectivePathogenLabel();
}
}

View File

@@ -0,0 +1,76 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api")
public class CatalogController {
private final CatalogService catalogService;
public CatalogController(CatalogService catalogService) {
this.catalogService = catalogService;
}
@GetMapping("/catalogs/summary")
public CatalogService.ActiveCatalogSummary catalogSummary() {
return catalogService.activeCatalogSummary();
}
@GetMapping("/admin")
public CatalogService.AdministrationOverview administrationOverview() {
return catalogService.administrationOverview();
}
@PostMapping("/admin/farmers")
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
return catalogService.saveFarmers(mutations);
}
@PostMapping("/admin/medications")
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
return catalogService.saveMedications(mutations);
}
@PostMapping("/admin/pathogens")
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
return catalogService.savePathogens(mutations);
}
@PostMapping("/admin/antibiotics")
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
return catalogService.saveAntibiotics(mutations);
}
@GetMapping("/portal/users")
public List<CatalogService.UserRow> users() {
return catalogService.listUsers();
}
@PostMapping("/portal/users")
public CatalogService.UserRow saveUser(@RequestBody CatalogService.UserMutation mutation) {
return catalogService.createOrUpdateUser(mutation);
}
@DeleteMapping("/portal/users/{id}")
public void deleteUser(@PathVariable String id) {
catalogService.deleteUser(id);
}
@PostMapping("/portal/users/{id}/password")
public void changePassword(@PathVariable String id, @RequestBody PasswordChangeRequest request) {
catalogService.changePassword(id, request.password());
}
public record PasswordChangeRequest(String password) {
}
}

View File

@@ -0,0 +1,78 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.PortalService;
import de.svencarstensen.muh.service.ReportService;
import de.svencarstensen.muh.service.SampleService;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
@RestController
@RequestMapping("/api/portal")
public class PortalController {
private final PortalService portalService;
private final ReportService reportService;
public PortalController(PortalService portalService, ReportService reportService) {
this.portalService = portalService;
this.reportService = reportService;
}
@GetMapping("/snapshot")
public PortalService.PortalSnapshot snapshot(
@RequestParam(required = false) String farmerBusinessKey,
@RequestParam(required = false) String farmerQuery,
@RequestParam(required = false) String cowQuery,
@RequestParam(required = false) Long sampleNumber,
@RequestParam(required = false) LocalDate date
) {
return portalService.snapshot(farmerBusinessKey, farmerQuery, cowQuery, sampleNumber, date);
}
@GetMapping("/reports")
public List<ReportService.ReportCandidate> reports() {
return reportService.reportCandidates();
}
@PostMapping("/reports/send")
public ReportService.DispatchResult send(@RequestBody ReportDispatchRequest request) {
return reportService.sendReports(request.sampleIds());
}
@PatchMapping("/reports/{sampleId}/block")
public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) {
return reportService.toggleReportBlocked(sampleId, request.blocked());
}
@GetMapping("/reports/{sampleId}/pdf")
public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) {
byte[] pdf = reportService.reportPdf(sampleId);
return ResponseEntity.ok()
.contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF))
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename("MUH-Bericht-" + sampleId + ".pdf")
.build()
.toString())
.body(pdf);
}
public record ReportDispatchRequest(List<String> sampleIds) {
}
public record BlockRequest(boolean blocked) {
}
}

View File

@@ -0,0 +1,66 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.SampleService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class SampleController {
private final SampleService sampleService;
public SampleController(SampleService sampleService) {
this.sampleService = sampleService;
}
@GetMapping("/dashboard")
public SampleService.DashboardOverview dashboardOverview() {
return sampleService.dashboardOverview();
}
@GetMapping("/dashboard/lookup/{sampleNumber}")
public SampleService.LookupResult lookup(@PathVariable long sampleNumber) {
return sampleService.lookup(sampleNumber);
}
@GetMapping("/samples/{id}")
public SampleService.SampleDetail sample(@PathVariable String id) {
return sampleService.getSample(id);
}
@GetMapping("/samples/by-number/{sampleNumber}")
public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) {
return sampleService.getSampleByNumber(sampleNumber);
}
@PostMapping("/samples")
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) {
return sampleService.createSample(request);
}
@PutMapping("/samples/{id}/registration")
public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) {
return sampleService.saveRegistration(id, request);
}
@PutMapping("/samples/{id}/anamnesis")
public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) {
return sampleService.saveAnamnesis(id, request);
}
@PutMapping("/samples/{id}/antibiogram")
public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) {
return sampleService.saveAntibiogram(id, request);
}
@PutMapping("/samples/{id}/therapy")
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) {
return sampleService.saveTherapy(id, request);
}
}

View File

@@ -0,0 +1,61 @@
package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService;
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.RestController;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
@RestController
@RequestMapping("/api/session")
public class SessionController {
private final CatalogService catalogService;
public SessionController(CatalogService catalogService) {
this.catalogService = catalogService;
}
@GetMapping("/users")
public List<CatalogService.UserOption> activeUsers() {
return catalogService.activeCatalogSummary().users();
}
@PostMapping("/login")
public CatalogService.UserOption login(@RequestBody LoginRequest request) {
return catalogService.loginByCode(request.code());
}
@PostMapping("/password-login")
public CatalogService.UserOption passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.identifier(), request.password());
}
@PostMapping("/register")
public CatalogService.UserOption register(@RequestBody RegistrationRequest request) {
return catalogService.registerCustomer(new CatalogService.RegistrationMutation(
request.companyName(),
request.address(),
request.email(),
request.password()
));
}
public record LoginRequest(@NotBlank String code) {
}
public record PasswordLoginRequest(@NotBlank String identifier, @NotBlank String password) {
}
public record RegistrationRequest(
@NotBlank String companyName,
@NotBlank String address,
@NotBlank String email,
@NotBlank String password
) {
}
}

View File

@@ -0,0 +1,29 @@
server:
port: 8090
spring:
application:
name: muh-backend
data:
mongodb:
uri: mongodb://192.168.180.25:27017/muh
jackson:
time-zone: Europe/Berlin
mail:
host: ${MUH_MAIL_HOST:}
port: ${MUH_MAIL_PORT:587}
username: ${MUH_MAIL_USERNAME:}
password: ${MUH_MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: ${MUH_MAIL_AUTH:false}
starttls:
enable: ${MUH_MAIL_STARTTLS:false}
muh:
cors:
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
mail:
enabled: ${MUH_MAIL_ENABLED:false}
from: ${MUH_MAIL_FROM:no-reply@muh.local}

View File

@@ -0,0 +1,34 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.PathogenKind;
import de.svencarstensen.muh.domain.QuarterFinding;
import de.svencarstensen.muh.domain.QuarterKey;
import de.svencarstensen.muh.domain.SampleWorkflowStep;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SampleWorkflowRulesTest {
@Test
void shouldSkipAntibiogramIfNoBacterialGrowthExists() {
List<QuarterFinding> quarters = List.of(
new QuarterFinding(QuarterKey.SINGLE, false, "ng", "NG", "Kein Wachstum", PathogenKind.NO_GROWTH, null, null)
);
assertEquals(SampleWorkflowStep.THERAPY, SampleWorkflowRules.nextStepAfterAnamnesis(quarters));
}
@Test
void shouldReturnOnlyOneAntibiogramTargetForSamePathogen() {
List<QuarterFinding> quarters = List.of(
new QuarterFinding(QuarterKey.LEFT_FRONT, false, "sau", "SAU", "Staph. aureus", PathogenKind.BACTERIAL, null, null),
new QuarterFinding(QuarterKey.RIGHT_FRONT, false, "sau", "SAU", "Staph. aureus", PathogenKind.BACTERIAL, null, null),
new QuarterFinding(QuarterKey.LEFT_REAR, false, "eco", "ECO", "E. coli", PathogenKind.BACTERIAL, null, null)
);
assertEquals(List.of(QuarterKey.LEFT_FRONT, QuarterKey.LEFT_REAR), SampleWorkflowRules.antibiogramTargets(quarters));
}
}