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

@@ -191,7 +191,7 @@
}, },
{ {
"id": "5:2194624907249454848", "id": "5:2194624907249454848",
"lastPropertyId": "6:5035828038544573244", "lastPropertyId": "7:5673785903451668117",
"name": "TaskStatusEntity", "name": "TaskStatusEntity",
"properties": [ "properties": [
{ {
@@ -275,7 +275,9 @@
"modelVersionParserMinimum": 5, "modelVersionParserMinimum": 5,
"retiredEntityUids": [], "retiredEntityUids": [],
"retiredIndexUids": [], "retiredIndexUids": [],
"retiredPropertyUids": [], "retiredPropertyUids": [
5673785903451668117
],
"retiredRelationUids": [], "retiredRelationUids": [],
"version": 1 "version": 1
} }

View File

@@ -240,7 +240,7 @@ final _entities = <obx_int.ModelEntity>[
obx_int.ModelEntity( obx_int.ModelEntity(
id: const obx_int.IdUid(5, 2194624907249454848), id: const obx_int.IdUid(5, 2194624907249454848),
name: 'TaskStatusEntity', name: 'TaskStatusEntity',
lastPropertyId: const obx_int.IdUid(6, 5035828038544573244), lastPropertyId: const obx_int.IdUid(7, 5673785903451668117),
flags: 0, flags: 0,
properties: <obx_int.ModelProperty>[ properties: <obx_int.ModelProperty>[
obx_int.ModelProperty( obx_int.ModelProperty(
@@ -371,7 +371,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
lastSequenceId: const obx_int.IdUid(0, 0), lastSequenceId: const obx_int.IdUid(0, 0),
retiredEntityUids: const [], retiredEntityUids: const [],
retiredIndexUids: const [], retiredIndexUids: const [],
retiredPropertyUids: const [], retiredPropertyUids: const [5673785903451668117],
retiredRelationUids: const [], retiredRelationUids: const [],
modelVersion: 5, modelVersion: 5,
modelVersionParserMinimum: 5, modelVersionParserMinimum: 5,
@@ -632,7 +632,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
}, },
objectToFB: (TaskStatusEntity object, fb.Builder fbb) { objectToFB: (TaskStatusEntity object, fb.Builder fbb) {
final taskIdOffset = fbb.writeString(object.taskId); final taskIdOffset = fbb.writeString(object.taskId);
fbb.startTable(7); fbb.startTable(8);
fbb.addInt64(0, object.id); fbb.addInt64(0, object.id);
fbb.addOffset(1, taskIdOffset); fbb.addOffset(1, taskIdOffset);
fbb.addBool(2, object.completed); fbb.addBool(2, object.completed);

View File

@@ -40,7 +40,6 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> { class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {}; final Set<String> _completedTasks = {};
final Set<String> _skippedTasks = {};
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
// Store SVG representations of signatures per task for later use // Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {}; final Map<String, String> _signatureSvgByTask = {};
@@ -61,7 +60,7 @@ class _TaskViewState extends State<TaskView> {
.toList(); .toList();
} }
/// Load task completion statuses from database and merge with JSON task states /// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async { Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses(); final statuses = await _databaseService.loadAllTaskStatuses();
setState(() { setState(() {
@@ -170,33 +169,26 @@ class _TaskViewState extends State<TaskView> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final task = _visibleTasks[index]; final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id); final isCompleted = _completedTasks.contains(task.id);
final isSkipped = _skippedTasks.contains(task.id);
final canBeCompletedNow = final canBeCompletedNow =
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index); !isCompleted && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status: // Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau // abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
final Color cardColor = final Color cardColor =
isCompleted isCompleted
? const Color(0xFFE8F5E9) // hellgrün ? const Color(0xFFE8F5E9) // hellgrün
: isSkipped
? const Color(0xFFFFF8E1) // hellgelb
: canBeCompletedNow : canBeCompletedNow
? Colors.white ? Colors.white
: const Color(0xFFF5F5F5); // hellgrau : const Color(0xFFF5F5F5); // hellgrau
final Color borderColor = final Color borderColor =
isCompleted isCompleted
? Colors.green[300]! ? Colors.green[300]!
: isSkipped
? Colors.amber[300]!
: canBeCompletedNow : canBeCompletedNow
? Colors.grey[300]! ? Colors.grey[300]!
: Colors.grey[200]!; : Colors.grey[200]!;
final Color circleColor = final Color circleColor =
isCompleted isCompleted
? Colors.green[600]! ? Colors.green[600]!
: isSkipped
? Colors.amber[600]!
: canBeCompletedNow : canBeCompletedNow
? Colors.deepPurple[400]! ? Colors.deepPurple[400]!
: Colors.grey[400]!; : Colors.grey[400]!;
@@ -250,7 +242,7 @@ class _TaskViewState extends State<TaskView> {
children: [ children: [
_buildTaskDisplayText( _buildTaskDisplayText(
task, task,
isCompleted || isSkipped, isCompleted,
index, index,
), ),
if (_getTaskStationLabel(task) != null) ...[ if (_getTaskStationLabel(task) != null) ...[
@@ -264,6 +256,28 @@ class _TaskViewState extends State<TaskView> {
), ),
), ),
], ],
if (task.optional) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.amber[50],
border: Border.all(color: Colors.amber[300]!),
borderRadius: BorderRadius.circular(12),
),
child: Text(
AppLocalizations.of(context).optional,
style: TextStyle(
fontSize: 11,
color: Colors.amber[800],
fontWeight: FontWeight.w600,
),
),
),
],
], ],
), ),
), ),
@@ -271,10 +285,6 @@ class _TaskViewState extends State<TaskView> {
const SizedBox(width: 8), const SizedBox(width: 8),
Icon(Icons.check_circle, color: Colors.green[600]), Icon(Icons.check_circle, color: Colors.green[600]),
], ],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
],
], ],
), ),
), ),
@@ -634,9 +644,7 @@ class _TaskViewState extends State<TaskView> {
if (index <= 0) return true; if (index <= 0) return true;
for (int i = 0; i < index; i++) { for (int i = 0; i < index; i++) {
final t = _visibleTasks[i]; final t = _visibleTasks[i];
if (!t.optional && if (!t.optional && !_completedTasks.contains(t.id)) {
!_completedTasks.contains(t.id) &&
!_skippedTasks.contains(t.id)) {
return false; return false;
} }
} }

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.9.15+1 version: 0.9.16+1
environment: environment:
sdk: ^3.7.0 sdk: ^3.7.0

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
package de.assecutor.votianlt.messaging; package de.assecutor.votianlt.messaging;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/** /**
@@ -23,6 +25,12 @@ public class WebSocketConfig implements WebSocketConfigurer {
@Value("${app.messaging.websocket.allowed-origins:*}") @Value("${app.messaging.websocket.allowed-origins:*}")
private String allowedOrigins; 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) { public WebSocketConfig(WebSocketService webSocketService) {
this.webSocketService = webSocketService; this.webSocketService = webSocketService;
} }
@@ -32,4 +40,13 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(",")) registry.addHandler(webSocketService, wsPath).setAllowedOrigins(allowedOrigins.split(","))
.addInterceptors(new HttpSessionHandshakeInterceptor()); .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; 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() { private void handleApplyStations() {
removeEmptyDeliveryStations();
revealPriceAndDetailsSection(); revealPriceAndDetailsSection();
if (!areAllStationsValidatedByGoogle()) { 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.notification.NotificationVariant;
import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon; 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.AttachEvent;
import com.vaadin.flow.component.DetachEvent; import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.UI; 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.LocationService;
import de.assecutor.votianlt.service.MessageService; import de.assecutor.votianlt.service.MessageService;
import de.assecutor.votianlt.service.TaskAssignmentService; 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 de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
@@ -88,6 +91,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
private final LocationService locationService; private final LocationService locationService;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
private final TaskAssignmentService taskAssignmentService; private final TaskAssignmentService taskAssignmentService;
private final SecurityService securityService;
@Value("${app.google.maps.api-key}") @Value("${app.google.maps.api-key}")
private String googleMapsApiKey; private String googleMapsApiKey;
@@ -103,7 +107,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService, PhotoRepository photoRepository, CommentRepository commentRepository, AppUserService appUserService,
MessageService messageService, JobHistoryService jobHistoryService, MessageService messageService, JobHistoryService jobHistoryService,
JobUpdateBroadcaster jobUpdateBroadcaster, LocationService locationService, JobUpdateBroadcaster jobUpdateBroadcaster, LocationService locationService,
ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService) { ServiceRepository serviceRepository, TaskAssignmentService taskAssignmentService,
SecurityService securityService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.cargoItemRepository = cargoItemRepository; this.cargoItemRepository = cargoItemRepository;
this.signatureRepository = signatureRepository; this.signatureRepository = signatureRepository;
@@ -116,6 +121,7 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
this.locationService = locationService; this.locationService = locationService;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.taskAssignmentService = taskAssignmentService; this.taskAssignmentService = taskAssignmentService;
this.securityService = securityService;
setSizeFull(); setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN, 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)); 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 // Create Job History Button for toolbar
Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory")); Button jobHistoryButton = new Button(getTranslation("jobsummary.button.jobhistory"));
jobHistoryButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); 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())); 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 toolbar with buttons
add(new ViewToolbar(getTranslation("jobsummary.title"), sendMessageButton, jobHistoryButton)); 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<CargoItem> cargo = cargoItemRepository.findByJobId(currentJobId);
List<BaseTask> tasks = taskAssignmentService.findTasksForJob(job); 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() { private VerticalLayout borderedBox() {
VerticalLayout box = new VerticalLayout(); VerticalLayout box = new VerticalLayout();
box.addClassName("summary-card"); box.addClassName("summary-card");

View File

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

View File

@@ -62,7 +62,7 @@ app.security.two-factor.enabled=true
# WebSocket Configuration # WebSocket Configuration
app.messaging.websocket.path=/ws/messaging 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.max-session-idle-timeout=300000
app.messaging.websocket.allowed-origins=* 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.task.button.text=Button-Text
jobsummary.button.schliessen=Schließen jobsummary.button.schliessen=Schließen
jobsummary.route.planned=Geplante Route 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
jobs.title=Aufträge jobs.title=Aufträge

View File

@@ -557,6 +557,14 @@ jobsummary.task.photo.taken=Tehtud fotod ({0})
jobsummary.task.button.text=Nupu tekst jobsummary.task.button.text=Nupu tekst
jobsummary.button.schliessen=Sulge jobsummary.button.schliessen=Sulge
jobsummary.route.planned=Planeeritud marsruut 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.title=Tellimused
jobs.filter.search=Otsi jobs.filter.search=Otsi
jobs.filter.search.placeholder=Otsi tellimuse numbri j\u00e4rgi... 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.task.button.text=Button Text
jobsummary.button.schliessen=Close jobsummary.button.schliessen=Close
jobsummary.route.planned=Planned Route 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
jobs.title=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.task.button.text=Texto del bot\u00f3n
jobsummary.button.schliessen=Cerrar jobsummary.button.schliessen=Cerrar
jobsummary.route.planned=Ruta planificada 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
jobs.title=Pedidos jobs.title=Pedidos

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Photos prises ({0})
jobsummary.task.button.text=Texte du bouton jobsummary.task.button.text=Texte du bouton
jobsummary.button.schliessen=Fermer jobsummary.button.schliessen=Fermer
jobsummary.route.planned=Itin\u00e9raire pr\u00e9vu 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
jobs.title=Missions jobs.title=Missions

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Padarytos nuotraukos ({0})
jobsummary.task.button.text=Mygtuko tekstas jobsummary.task.button.text=Mygtuko tekstas
jobsummary.button.schliessen=Uždaryti jobsummary.button.schliessen=Uždaryti
jobsummary.route.planned=Planuotas maršrutas 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
jobs.title=Užsakymai 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.task.button.text=Pogas teksts
jobsummary.button.schliessen=Aizvērt jobsummary.button.schliessen=Aizvērt
jobsummary.route.planned=Plānotais maršruts 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
jobs.title=Uzdevumi jobs.title=Uzdevumi

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Wykonane zdj\u0119cia ({0})
jobsummary.task.button.text=Tekst przycisku jobsummary.task.button.text=Tekst przycisku
jobsummary.button.schliessen=Zamknij jobsummary.button.schliessen=Zamknij
jobsummary.route.planned=Planowana trasa 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
jobs.title=Zlecenia jobs.title=Zlecenia

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=Сделанные фотографии ({0})
jobsummary.task.button.text=Текст кнопки jobsummary.task.button.text=Текст кнопки
jobsummary.button.schliessen=Закрыть jobsummary.button.schliessen=Закрыть
jobsummary.route.planned=Запланированный маршрут 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
jobs.title=Заказы jobs.title=Заказы

View File

@@ -610,6 +610,14 @@ jobsummary.task.photo.taken=\u00c7ekilen Foto\u011fraflar ({0})
jobsummary.task.button.text=Buton Metni jobsummary.task.button.text=Buton Metni
jobsummary.button.schliessen=Kapat jobsummary.button.schliessen=Kapat
jobsummary.route.planned=Planlanan Rota 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
jobs.title=\u0130\u015fler 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;
}
}