Version 0.9.16: Skip-Button entfernt, manuelle Auftragsbeendigung und E-Mail-Verbesserungen

App:
- Skip-Button für optionale Aufgaben entfernt — optionale Aufgaben blockieren
  nicht mehr den Fortschritt und können jederzeit nachträglich bearbeitet werden

Backend:
- Manuelle Auftragsbeendigung mit Begründung in der Job-Zusammenfassung hinzugefügt
- Leere Lieferstationen werden beim Übernehmen automatisch entfernt
- E-Mail-Benachrichtigungen zeigen jetzt den tatsächlichen App-Benutzernamen an
- WebSocket: konfigurierbare Max-Nachrichtengröße und Session-Idle-Timeout
- docker_push.sh Pfadkorrektur
- Lokalisierungen für 10 Sprachen aktualisiert
- EmailService-Test hinzugefügt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 16:43:38 +02:00
parent bba5733783
commit 1ac755bcbd
22 changed files with 516 additions and 46 deletions

View File

@@ -4,7 +4,7 @@ set -euo pipefail
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REGISTRY_IMAGE="registry.assecutor.org/votianlt"
readonly BACKEND_DIR="${SCRIPT_DIR}/backend"
readonly BACKEND_DIR="${SCRIPT_DIR}"
usage() {
cat <<'EOF'

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.15</revision>
<revision>0.9.16</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

View File

@@ -1,10 +1,12 @@
package de.assecutor.votianlt.messaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/**
@@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Value("${app.messaging.websocket.allowed-origins:*}")
private String allowedOrigins;
@Value("${app.messaging.websocket.max-text-message-size:10485760}")
private int maxTextMessageSize;
@Value("${app.messaging.websocket.max-session-idle-timeout:300000}")
private long maxSessionIdleTimeout;
public WebSocketConfig(WebSocketService webSocketService) {
this.webSocketService = webSocketService;
}
@@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(maxTextMessageSize);
container.setMaxBinaryMessageBufferSize(maxTextMessageSize);
container.setMaxSessionIdleTimeout(maxSessionIdleTimeout);
return container;
}
}

View File

@@ -2093,7 +2093,103 @@ public class AddJobView extends Main implements HasDynamicTitle {
return null;
}
/**
* Entfernt alle leeren (nicht editierten) Lieferstationen, sofern mindestens
* eine valide Lieferstation übrig bleibt. Wird aufgerufen bevor die Stationen
* übernommen werden.
*/
private void removeEmptyDeliveryStations() {
// Indizes der leeren Stationen sammeln (absteigend, damit beim Entfernen die Indizes stabil bleiben)
List<Integer> emptyIndices = new ArrayList<>();
for (int i = 0; i < deliveryStationsState.size(); i++) {
if (hasDeliveryStationValidationErrors(deliveryStationsState.get(i))) {
emptyIndices.add(i);
}
}
// Mindestens eine valide Station muss übrig bleiben
if (emptyIndices.size() >= deliveryStationsState.size()) {
return;
}
// Von hinten nach vorne entfernen, damit Indizes stabil bleiben
for (int k = emptyIndices.size() - 1; k >= 0; k--) {
int idx = emptyIndices.get(k);
deliveryStationTilesList.remove(idx);
deliveryStationsState.remove(idx);
deliveryStationsSaveAddress.remove(idx);
deliveryStationsMailState.remove(idx);
deliveryStationsValidatedByGoogle.remove(idx);
deliveryStationTasksState.remove(idx);
Div removedSlot = deliveryStationSlotList.remove(idx);
deliveryStationDistanceChips.remove(idx);
pickupToDeliveryRouteResults.remove(idx);
stationsGridContainer.remove(removedSlot);
}
// Tasks und Routen-Maps re-indizieren
Map<Integer, List<BaseTask>> reindexedTasks = new HashMap<>();
for (Map.Entry<Integer, List<BaseTask>> entry : deliveryStationTasksState.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedTasks.put(newIdx, entry.getValue());
}
deliveryStationTasksState.clear();
deliveryStationTasksState.putAll(reindexedTasks);
Map<Integer, RouteCalculationResult> reindexedRoutes = new HashMap<>();
for (Map.Entry<Integer, RouteCalculationResult> entry : pickupToDeliveryRouteResults.entrySet()) {
int oldIdx = entry.getKey();
int newIdx = oldIdx - (int) emptyIndices.stream().filter(ei -> ei < oldIdx).count();
reindexedRoutes.put(newIdx, entry.getValue());
}
pickupToDeliveryRouteResults.clear();
pickupToDeliveryRouteResults.putAll(reindexedRoutes);
// Service-Zuordnungen anpassen
for (SelectedServiceEntry selectedService : selectedServices) {
Integer stationOrder = selectedService.getDeliveryStationOrder();
if (stationOrder == null) {
continue;
}
if (emptyIndices.contains(stationOrder)) {
selectedService.setDeliveryStationOrder(deliveryStationsState.isEmpty() ? null : 0);
} else {
int newOrder = stationOrder - (int) emptyIndices.stream().filter(ei -> ei < stationOrder).count();
selectedService.setDeliveryStationOrder(newOrder);
}
}
// Tiles neu nummerieren und Click-Listener aktualisieren
for (int i = 0; i < deliveryStationTilesList.size(); i++) {
StationTile t = deliveryStationTilesList.get(i);
int newNumber = i + 1;
t.updateStationNumber(newNumber);
t.updateTitle(getTranslation("addjob.station.delivery", newNumber));
final int newIdx = i;
t.setClickListener(tt -> openDeliveryDialog(tt, newIdx));
if (i == 0) {
t.setDeleteListener(null);
}
}
// "+" Button wieder anzeigen falls unter Maximum
if (deliveryStationTilesList.size() < MAX_DELIVERY_STATIONS
&& addStationButtonSlot.getParent().isEmpty()) {
stationsGridContainer.add(addStationButtonSlot);
}
if (servicesGrid != null) {
servicesGrid.getDataProvider().refreshAll();
}
updatePriceSummary();
triggerValidation();
updateTabLabels();
}
private void handleApplyStations() {
removeEmptyDeliveryStations();
revealPriceAndDetailsSection();
if (!areAllStationsValidatedByGoogle()) {

View File

@@ -14,6 +14,7 @@ import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI;
@@ -59,6 +60,8 @@ import de.assecutor.votianlt.service.JobUpdateBroadcaster;
import de.assecutor.votianlt.service.LocationService;
import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.service.TaskAssignmentService;
import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId;
@@ -88,6 +91,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
private final LocationService locationService;
private final ServiceRepository serviceRepository;
private final TaskAssignmentService taskAssignmentService;
private final SecurityService securityService;
@Value("${app.google.maps.api-key}")
private String googleMapsApiKey;
@@ -103,7 +107,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService, JobHistoryService jobHistoryService,
JobUpdateBroadcaster jobUpdateBroadcaster, LocationService locationService,
ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService) {
ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService,
SecurityService securityService) {
this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository;
this.signatureRepository = signatureRepository;
@@ -116,6 +121,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
this.locationService = locationService;
this.serviceRepository = serviceRepository;
this.taskAssignmentService = taskAssignmentService;
this.securityService = securityService;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -212,6 +218,15 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("message-details/" + appUserId + "/" + conversationId));
});
// Create Manual Completion Button for app jobs (digital processing)
Button manualCompleteButton = null;
if (job.isDigitalProcessing() && job.getStatus() != JobStatus.COMPLETED
&& job.getStatus() != JobStatus.CANCELLED) {
manualCompleteButton = new Button(getTranslation("jobsummary.button.manualcomplete"));
manualCompleteButton.addThemeVariants(ButtonVariant.LUMO_ERROR);
manualCompleteButton.addClickListener(e -> openManualCompleteDialog(job));
}
// Create Job History Button for toolbar
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
@@ -219,8 +234,13 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
getUI().ifPresent(ui -> ui.navigate("job_history/" + job.getId().toHexString()));
});
// Add toolbar with both buttons in top right (Send Message button on the left)
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
// Add toolbar with buttons
if (manualCompleteButton != null) {
add(new ViewToolbar(getTranslation("jobsummary.title"), manualCompleteButton, sendMessageButton,
jobHistoryButton));
} else {
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton));
}
List<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job);
@@ -359,6 +379,75 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
}
}
private void openManualCompleteDialog(Job job) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("jobsummary.dialog.manualcomplete.title"), "560px");
VerticalLayout dialogContent = DialogStylingHelper.createContentLayout("520px");
Span warningText = new Span(getTranslation("jobsummary.dialog.manualcomplete.text", job.getJobNumber()));
warningText.getStyle().set("color", "var(--lumo-error-text-color)");
TextArea reasonField = new TextArea(getTranslation("jobsummary.dialog.manualcomplete.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("100px");
reasonField.setRequired(true);
dialogContent.add(warningText, reasonField);
dialog.add(DialogStylingHelper.wrapContent(dialogContent));
HorizontalLayout buttonBar = new HorizontalLayout();
buttonBar.setWidthFull();
buttonBar.setJustifyContentMode(HorizontalLayout.JustifyContentMode.END);
buttonBar.setSpacing(true);
Button cancelButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.cancel"),
e -> dialog.close());
Button confirmButton = new Button(getTranslation("jobsummary.dialog.manualcomplete.confirm"));
confirmButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
confirmButton.addClickListener(e -> {
String reason = reasonField.getValue();
if (reason == null || reason.trim().isEmpty()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("jobsummary.dialog.manualcomplete.reason.required"));
return;
}
try {
JobStatus oldStatus = job.getStatus();
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
String currentUser = securityService.getCurrentUsername();
jobHistoryService.logStatusChange(job, oldStatus, JobStatus.COMPLETED, currentUser);
String description = String.format("Auftrag manuell beendet von %s. Begründung: %s",
currentUser, reason.trim());
jobHistoryService.logCustomEvent(job.getId(),
getTranslation("jobsummary.history.manualcomplete.reason"),
description, currentUser, JobHistoryType.STATUS_CHANGE);
dialog.close();
Notification
.show(getTranslation("jobsummary.notification.completed", job.getJobNumber()), 3000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
getUI().ifPresent(ui -> ui.navigate("job_summary/" + job.getId().toHexString()));
} catch (Exception ex) {
Notification
.show(getTranslation("jobsummary.notification.complete.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_END)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
});
buttonBar.add(cancelButton, confirmButton);
dialog.getFooter().add(buttonBar);
dialog.open();
}
private VerticalLayout borderedBox() {
VerticalLayout box = new VerticalLayout();
box.addClassName("summary-card");

View File

@@ -1,7 +1,9 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@@ -22,6 +24,7 @@ public class EmailService {
private final UserRepository userRepository;
private final JobRepository jobRepository;
private final TaskAssignmentService taskAssignmentService;
private final AppUserRepository appUserRepository;
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
@@ -52,8 +55,10 @@ public class EmailService {
return;
}
String completedByName = resolveCompletedByName(job, completedBy);
// Send email
sendEmail(user, job, taskType);
sendEmail(user, job, taskType, completedByName);
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId);
@@ -63,7 +68,7 @@ public class EmailService {
}
}
private void sendEmail(User user, Job job, String taskType) {
private void sendEmail(User user, Job job, String taskType, String completedByName) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
@@ -71,18 +76,17 @@ public class EmailService {
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
String taskTypeName = getTaskTypeDisplayName(taskType);
StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n");
body.append("eine Aufgabe wurde von ").append(appUserName).append(" abgeschlossen:\n\n");
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
if (job.getDeliveryCompany() != null) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
}
body.append("Aufgabe: ").append(taskTypeName).append("\n");
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
String deliveryCities = job.getDeliveryCitiesDisplay();
if (job.getPickupCity() != null || deliveryCities != null) {
@@ -121,16 +125,55 @@ public class EmailService {
return fullName.isEmpty() ? "Benutzer" : fullName;
}
private String buildAppUserName(User user) {
private String buildAppUserName(AppUser appUser) {
StringBuilder name = new StringBuilder();
if (user.getFirstname() != null && !user.getFirstname().isBlank()) {
name.append(user.getFirstname()).append(" ");
if (appUser.getVorname() != null && !appUser.getVorname().isBlank()) {
name.append(appUser.getVorname()).append(" ");
}
if (user.getName() != null && !user.getName().isBlank()) {
name.append(user.getName());
if (appUser.getNachname() != null && !appUser.getNachname().isBlank()) {
name.append(appUser.getNachname());
}
String fullName = name.toString().trim();
return fullName.isEmpty() ? "App-Benutzer" : fullName;
if (!fullName.isEmpty()) {
return fullName;
}
if (appUser.getBezeichnung() != null && !appUser.getBezeichnung().isBlank()) {
return appUser.getBezeichnung().trim();
}
if (appUser.getEmail() != null && !appUser.getEmail().isBlank()) {
return appUser.getEmail().trim();
}
return "App-Benutzer";
}
private String resolveCompletedByName(Job job, String completedBy) {
Optional<AppUser> assignedAppUser = findAppUserById(job != null ? job.getAppUser() : null);
if (assignedAppUser.isPresent()) {
return buildAppUserName(assignedAppUser.get());
}
if (completedBy != null && !completedBy.isBlank() && !"Unknown".equalsIgnoreCase(completedBy)) {
Optional<AppUser> completingAppUser = findAppUserById(completedBy);
if (completingAppUser.isPresent()) {
return buildAppUserName(completingAppUser.get());
}
return completedBy;
}
return "App-Benutzer";
}
private Optional<AppUser> findAppUserById(String appUserId) {
if (appUserId == null || appUserId.isBlank()) {
return Optional.empty();
}
try {
return appUserRepository.findById(new ObjectId(appUserId));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
private String getTaskTypeDisplayName(String taskType) {
@@ -173,8 +216,10 @@ public class EmailService {
return;
}
String completedByName = resolveCompletedByName(job, completedBy);
// Send job completion email
sendJobCompletionEmail(user, job);
sendJobCompletionEmail(user, job, completedByName);
log.info("Job completion notification sent to {} for job {}", user.getEmail(), job.getJobNumber());
} catch (Exception e) {
@@ -182,7 +227,7 @@ public class EmailService {
}
}
private void sendJobCompletionEmail(User user, Job job) {
private void sendJobCompletionEmail(User user, Job job, String completedByName) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername);
message.setTo(user.getEmail());
@@ -190,7 +235,6 @@ public class EmailService {
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
String fullName = buildFullName(user);
String appUserName = buildAppUserName(user);
// Count completed tasks
var allTasks = taskAssignmentService.findTasksForJob(job);
@@ -220,7 +264,7 @@ public class EmailService {
}
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
body.append("Abgeschlossen von: ").append(appUserName).append("\n\n");
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
body.append("Mit freundlichen Grüßen,\n");

View File

@@ -62,7 +62,7 @@ app.security.two-factor.enabled=true
# WebSocket Configuration
app.messaging.websocket.path=/ws/messaging
app.messaging.websocket.max-text-message-size=65536
app.messaging.websocket.max-text-message-size=10485760
app.messaging.websocket.max-session-idle-timeout=300000
app.messaging.websocket.allowed-origins=*

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Aufgenommene Fotos ({0})
jobsummary.task.button.text=Button-Text
jobsummary.button.schliessen=Schließen
jobsummary.route.planned=Geplante Route
jobsummary.button.manualcomplete=Manuell beenden
jobsummary.dialog.manualcomplete.title=Auftrag manuell beenden
jobsummary.dialog.manualcomplete.text=Der Auftrag {0} wird jetzt manuell abgeschlossen. Danach kann er nicht mehr per App weiter bearbeitet werden.
jobsummary.dialog.manualcomplete.reason=Begründung
jobsummary.dialog.manualcomplete.reason.required=Bitte geben Sie eine Begründung ein
jobsummary.dialog.manualcomplete.cancel=Abbrechen
jobsummary.dialog.manualcomplete.confirm=Akzeptiert
jobsummary.history.manualcomplete.reason=Manuell beendet
# Jobs
jobs.title=Aufträge

View File

@@ -557,6 +557,14 @@ jobsummary.task.photo.taken=Tehtud fotod ({0})
jobsummary.task.button.text=Nupu tekst
jobsummary.button.schliessen=Sulge
jobsummary.route.planned=Planeeritud marsruut
jobsummary.button.manualcomplete=Lõpeta käsitsi
jobsummary.dialog.manualcomplete.title=Lõpeta tellimus käsitsi
jobsummary.dialog.manualcomplete.text=Tellimus {0} lõpetatakse nüüd käsitsi. Pärast seda ei saa seda enam rakenduse kaudu töödelda.
jobsummary.dialog.manualcomplete.reason=Põhjendus
jobsummary.dialog.manualcomplete.reason.required=Palun sisestage põhjendus
jobsummary.dialog.manualcomplete.cancel=Tühista
jobsummary.dialog.manualcomplete.confirm=Nõustu
jobsummary.history.manualcomplete.reason=Käsitsi lõpetatud
jobs.title=Tellimused
jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi...

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Photos taken ({0})
jobsummary.task.button.text=Button Text
jobsummary.button.schliessen=Close
jobsummary.route.planned=Planned Route
jobsummary.button.manualcomplete=Complete manually
jobsummary.dialog.manualcomplete.title=Complete job manually
jobsummary.dialog.manualcomplete.text=Job {0} will now be completed manually. It can no longer be processed via the app afterwards.
jobsummary.dialog.manualcomplete.reason=Reason
jobsummary.dialog.manualcomplete.reason.required=Please enter a reason
jobsummary.dialog.manualcomplete.cancel=Cancel
jobsummary.dialog.manualcomplete.confirm=Accept
jobsummary.history.manualcomplete.reason=Manually completed
# Jobs
jobs.title=Jobs

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Fotos tomadas ({0})
jobsummary.task.button.text=Texto del bot\u00f3n
jobsummary.button.schliessen=Cerrar
jobsummary.route.planned=Ruta planificada
jobsummary.button.manualcomplete=Finalizar manualmente
jobsummary.dialog.manualcomplete.title=Finalizar pedido manualmente
jobsummary.dialog.manualcomplete.text=El pedido {0} se completar\u00e1 manualmente. Despu\u00e9s ya no podr\u00e1 ser procesado a trav\u00e9s de la aplicaci\u00f3n.
jobsummary.dialog.manualcomplete.reason=Motivo
jobsummary.dialog.manualcomplete.reason.required=Por favor, introduzca un motivo
jobsummary.dialog.manualcomplete.cancel=Cancelar
jobsummary.dialog.manualcomplete.confirm=Aceptar
jobsummary.history.manualcomplete.reason=Finalizado manualmente
# Jobs
jobs.title=Pedidos

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Photos prises ({0})
jobsummary.task.button.text=Texte du bouton
jobsummary.button.schliessen=Fermer
jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu
jobsummary.button.manualcomplete=Terminer manuellement
jobsummary.dialog.manualcomplete.title=Terminer la commande manuellement
jobsummary.dialog.manualcomplete.text=La commande {0} va maintenant \u00eatre termin\u00e9e manuellement. Elle ne pourra plus \u00eatre trait\u00e9e via l\u2019application par la suite.
jobsummary.dialog.manualcomplete.reason=Motif
jobsummary.dialog.manualcomplete.reason.required=Veuillez saisir un motif
jobsummary.dialog.manualcomplete.cancel=Annuler
jobsummary.dialog.manualcomplete.confirm=Accepter
jobsummary.history.manualcomplete.reason=Termin\u00e9 manuellement
# Jobs
jobs.title=Missions

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Padarytos nuotraukos ({0})
jobsummary.task.button.text=Mygtuko tekstas
jobsummary.button.schliessen=Uždaryti
jobsummary.route.planned=Planuotas maršrutas
jobsummary.button.manualcomplete=Užbaigti rankiniu būdu
jobsummary.dialog.manualcomplete.title=Užbaigti užsakymą rankiniu būdu
jobsummary.dialog.manualcomplete.text=Užsakymas {0} dabar bus užbaigtas rankiniu būdu. Po to jo nebebus galima apdoroti per programėlę.
jobsummary.dialog.manualcomplete.reason=Priežastis
jobsummary.dialog.manualcomplete.reason.required=Prašome įvesti priežastį
jobsummary.dialog.manualcomplete.cancel=Atšaukti
jobsummary.dialog.manualcomplete.confirm=Priimti
jobsummary.history.manualcomplete.reason=Užbaigta rankiniu būdu
# Jobs
jobs.title=Užsakymai

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Uzņemtās fotogrāfijas ({0})
jobsummary.task.button.text=Pogas teksts
jobsummary.button.schliessen=Aizvērt
jobsummary.route.planned=Plānotais maršruts
jobsummary.button.manualcomplete=Pabeigt manuāli
jobsummary.dialog.manualcomplete.title=Pabeigt pasūtījumu manuāli
jobsummary.dialog.manualcomplete.text=Pasūtījums {0} tagad tiks pabeigts manuāli. Pēc tam to vairs nevarēs apstrādāt, izmantojot lietotni.
jobsummary.dialog.manualcomplete.reason=Pamatojums
jobsummary.dialog.manualcomplete.reason.required=Lūdzu, ievadiet pamatojumu
jobsummary.dialog.manualcomplete.cancel=Atcelt
jobsummary.dialog.manualcomplete.confirm=Apstiprināt
jobsummary.history.manualcomplete.reason=Pabeigts manuāli
# Jobs
jobs.title=Uzdevumi

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Wykonane zdj\u0119cia ({0})
jobsummary.task.button.text=Tekst przycisku
jobsummary.button.schliessen=Zamknij
jobsummary.route.planned=Planowana trasa
jobsummary.button.manualcomplete=Zako\u0144cz r\u0119cznie
jobsummary.dialog.manualcomplete.title=Zako\u0144cz zlecenie r\u0119cznie
jobsummary.dialog.manualcomplete.text=Zlecenie {0} zostanie teraz zako\u0144czone r\u0119cznie. Po tym nie b\u0119dzie mo\u017cna go dalej obs\u0142ugiwa\u0107 przez aplikacj\u0119.
jobsummary.dialog.manualcomplete.reason=Uzasadnienie
jobsummary.dialog.manualcomplete.reason.required=Prosz\u0119 poda\u0107 uzasadnienie
jobsummary.dialog.manualcomplete.cancel=Anuluj
jobsummary.dialog.manualcomplete.confirm=Akceptuj
jobsummary.history.manualcomplete.reason=Zako\u0144czono r\u0119cznie
# Jobs
jobs.title=Zlecenia

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Сделанные фотографии ({0})
jobsummary.task.button.text=Текст кнопки
jobsummary.button.schliessen=Закрыть
jobsummary.route.planned=Запланированный маршрут
jobsummary.button.manualcomplete=Завершить вручную
jobsummary.dialog.manualcomplete.title=Завершить заказ вручную
jobsummary.dialog.manualcomplete.text=Заказ {0} будет завершён вручную. После этого его больше нельзя будет обрабатывать через приложение.
jobsummary.dialog.manualcomplete.reason=Обоснование
jobsummary.dialog.manualcomplete.reason.required=Пожалуйста, укажите обоснование
jobsummary.dialog.manualcomplete.cancel=Отмена
jobsummary.dialog.manualcomplete.confirm=Принять
jobsummary.history.manualcomplete.reason=Завершено вручную
# Jobs
jobs.title=Заказы

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=\u00c7ekilen Foto\u011fraflar ({0})
jobsummary.task.button.text=Buton Metni
jobsummary.button.schliessen=Kapat
jobsummary.route.planned=Planlanan Rota
jobsummary.button.manualcomplete=Manuel olarak tamamla
jobsummary.dialog.manualcomplete.title=Siparişi manuel olarak tamamla
jobsummary.dialog.manualcomplete.text=Sipariş {0} şimdi manuel olarak tamamlanacak. Bundan sonra uygulama üzerinden işlenemez.
jobsummary.dialog.manualcomplete.reason=Gerekçe
jobsummary.dialog.manualcomplete.reason.required=Lütfen bir gerekçe girin
jobsummary.dialog.manualcomplete.cancel=İptal
jobsummary.dialog.manualcomplete.confirm=Kabul et
jobsummary.history.manualcomplete.reason=Manuel olarak tamamlandı
# Jobs
jobs.title=\u0130\u015fler

View File

@@ -0,0 +1,134 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.TaskRepository;
import de.assecutor.votianlt.repository.UserRepository;
import java.util.List;
import java.util.Optional;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private JobRepository jobRepository;
@Mock
private TaskRepository taskRepository;
@Mock
private AppUserRepository appUserRepository;
@Mock
private JavaMailSender mailSender;
@Captor
private ArgumentCaptor<SimpleMailMessage> mailCaptor;
private EmailService emailService;
private TaskAssignmentService taskAssignmentService;
@BeforeEach
void setUp() {
taskAssignmentService = new TaskAssignmentService(taskRepository, jobRepository);
emailService = new EmailService(userRepository, jobRepository, taskAssignmentService, appUserRepository,
mailSender);
ReflectionTestUtils.setField(emailService, "smtpUsername", "noreply@example.com");
}
@Test
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
}
@Test
void sendJobCompletionNotificationUsesAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
when(taskRepository.findByJobIdOrderByTaskOrderAsc(job.getId())).thenReturn(List.of(
createConfirmationTask(0, "OK"),
createConfirmationTask(1, "Weiter")));
emailService.sendJobCompletionNotification(job.getId(), "ignored");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
}
private User createWebUser() {
User user = new User();
user.setId(new ObjectId());
user.setTitle("Dr.");
user.setFirstname("Anna");
user.setName("Unternehmer");
user.setEmail("anna@example.com");
return user;
}
private AppUser createAssignedAppUser() {
AppUser appUser = new AppUser();
appUser.setId(new ObjectId());
appUser.setVorname("Max");
appUser.setNachname("Mustermann");
appUser.setBezeichnung("Fahrer Max");
return appUser;
}
private Job createJob(User webUser, AppUser assignedAppUser) {
Job job = new Job();
job.setId(new ObjectId());
job.setCreatedBy(webUser.getId().toHexString());
job.setAppUser(assignedAppUser.getId().toHexString());
job.setJobNumber("JOB-2026-001");
job.setDeliveryCompany("Beispiel GmbH");
job.setPickupCity("Berlin");
job.setDeliveryCity("Hamburg");
return job;
}
private ConfirmationTask createConfirmationTask(int taskOrder, String buttonText) {
ConfirmationTask task = new ConfirmationTask(buttonText);
task.setTaskOrder(taskOrder);
return task;
}
}