From b642f429542c74320aa523ba54b3c779f70200f6 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 1 Oct 2026 11:31:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Auftragsdetails=20und=20Stationsabschlu?= =?UTF-8?q?ss=20f=C3=BCr=20Version=200.9.24?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen. Stationsabschlüsse und Benachrichtigungen absichern sowie Historie um Aufgabenbeschreibungen und ausführende Nutzer erweitern. Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben. --- app/lib/chats_view.dart | 6 +- app/lib/jobs_view.dart | 25 +- app/lib/l10n/app_localizations_de.dart | 33 +-- app/lib/l10n/localization_helpers.dart | 7 + app/lib/models/chat.dart | 2 +- app/lib/models/job.dart | 2 +- app/lib/services/chat_service.dart | 6 +- app/lib/services/notification_service.dart | 22 +- app/lib/services/websocket_service.dart | 16 +- app/pubspec.yaml | 2 +- backend/pom.xml | 2 +- .../ai/service/AiStatisticsService.java | 26 +- .../controller/MessageController.java | 170 ++++++++----- .../votianlt/messaging/MessagingConfig.java | 6 +- .../java/de/assecutor/votianlt/model/Job.java | 3 + .../assecutor/votianlt/model/JobHistory.java | 6 +- .../votianlt/pages/service/AddJobService.java | 2 +- .../votianlt/pages/view/JobHistoryView.java | 26 +- .../votianlt/pages/view/JobSummaryView.java | 21 +- .../votianlt/service/EmailService.java | 86 ++++++- .../votianlt/service/JobHistoryService.java | 126 ++++++++- .../src/main/resources/messages_de.properties | 26 +- .../src/main/resources/messages_ee.properties | 2 +- .../src/main/resources/messages_en.properties | 2 +- .../src/main/resources/messages_es.properties | 2 +- .../src/main/resources/messages_fr.properties | 2 +- .../src/main/resources/messages_lt.properties | 2 +- .../src/main/resources/messages_lv.properties | 2 +- .../src/main/resources/messages_pl.properties | 2 +- .../src/main/resources/messages_ru.properties | 2 +- .../src/main/resources/messages_tr.properties | 2 +- .../controller/StationCompletionTest.java | 240 ++++++++++++++++-- .../controller/TaskCompletionHistoryTest.java | 101 ++++++++ .../pages/service/OrderNumberTest.java | 50 ++++ .../view/JobSummaryTaskDescriptionTest.java | 33 +++ .../votianlt/service/EmailServiceTest.java | 83 ++++++ .../service/JobHistoryServiceTest.java | 144 +++++++++++ 37 files changed, 1067 insertions(+), 223 deletions(-) create mode 100644 backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/pages/service/OrderNumberTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/pages/view/JobSummaryTaskDescriptionTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java diff --git a/app/lib/chats_view.dart b/app/lib/chats_view.dart index f0b71f2..eb37c90 100644 --- a/app/lib/chats_view.dart +++ b/app/lib/chats_view.dart @@ -120,10 +120,10 @@ class _ChatsViewState extends State { title: Text(() { if (isJobChat) { if (jobNumber != null && jobNumber.isNotEmpty) { - return 'Job $jobNumber'; + return 'Auftrag $jobNumber'; } if (showJobId) { - return 'Job $jobId'; + return 'Auftrag $jobId'; } } return localizedChatTitle(context, chat); @@ -154,7 +154,7 @@ class _ChatsViewState extends State { ), ), child: Text( - isJobChat ? 'JOB' : 'ALLG', + isJobChat ? 'AUFTRAG' : 'ALLG', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, diff --git a/app/lib/jobs_view.dart b/app/lib/jobs_view.dart index 018a697..d7e5b37 100644 --- a/app/lib/jobs_view.dart +++ b/app/lib/jobs_view.dart @@ -145,7 +145,7 @@ class _JobsViewState extends State with RouteAware { if (mounted) { final message = jobNumber != null - ? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}' + ? 'Auftrag $jobNumber ${AppLocalizations.of(context).jobRemoved}' : AppLocalizations.of(context).jobRemoved; _showSnack(message, backgroundColor: AppColors.warning); } @@ -1406,7 +1406,7 @@ class _JobsViewState extends State with RouteAware { ], ), const SizedBox(height: 8), - // Dates and Price + // Creation date Row( children: [ Expanded( @@ -1428,27 +1428,6 @@ class _JobsViewState extends State with RouteAware { ], ), ), - if (job.price > 0) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.green[50], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green[200]!), - ), - child: Text( - '${job.price.toStringAsFixed(2)} €', - style: TextStyle( - fontSize: 12, - color: Colors.green[700], - fontWeight: FontWeight.w600, - ), - ), - ), - ], ], ), ], diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index d05ae92..e42f481 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -52,7 +52,7 @@ class AppLocalizationsDe extends AppLocalizations { // ==================== NAVIGATION ==================== @override - String get jobs => 'Jobs'; + String get jobs => 'Aufträge'; @override String get availableJobs => 'Auftragsliste'; @@ -155,10 +155,11 @@ class AppLocalizationsDe extends AppLocalizations { // ==================== JOBS ==================== @override - String get noJobsAssigned => 'Keine Jobs zugewiesen'; + String get noJobsAssigned => 'Keine Aufträge zugewiesen'; @override - String get noJobsMessage => 'Ihre zugewiesenen Jobs werden hier angezeigt.'; + String get noJobsMessage => + 'Ihre zugewiesenen Aufträge werden hier angezeigt.'; @override String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren'; @@ -206,13 +207,13 @@ class AppLocalizationsDe extends AppLocalizations { String get dimensions => 'Abmessungen'; @override - String get jobDeleted => 'Job gelöscht'; + String get jobDeleted => 'Auftrag gelöscht'; @override - String get jobDeleteError => 'Fehler beim Löschen des Jobs'; + String get jobDeleteError => 'Fehler beim Löschen des Auftrags'; @override - String get jobCompleted => 'Job abgeschlossen'; + String get jobCompleted => 'Auftrag abgeschlossen'; @override String get from => 'Von'; @@ -221,10 +222,11 @@ class AppLocalizationsDe extends AppLocalizations { String get to => 'nach'; @override - String get jobsUpdated => 'Jobs aktualisiert'; + String get jobsUpdated => 'Aufträge aktualisiert'; @override - String get connectionRestored => 'Verbindung wiederhergestellt. Lade Jobs...'; + String get connectionRestored => + 'Verbindung wiederhergestellt. Lade Aufträge...'; @override String get connectionLost => 'Verbindung verloren. Offline.'; @@ -233,13 +235,13 @@ class AppLocalizationsDe extends AppLocalizations { String get offline => 'Offline'; @override - String get deleteJob => 'Job löschen'; + String get deleteJob => 'Auftrag löschen'; @override String get jobRemoved => 'wurde entfernt'; @override - String get newJobReceived => 'Neuer Job erhalten'; + String get newJobReceived => 'Neuer Auftrag erhalten'; @override String get jobDetails => 'Auftragsdetails'; @@ -258,7 +260,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noDeliveryStationsMessage => - 'Dieser Job enthält aktuell keine Lieferstationen.'; + 'Dieser Auftrag enthält aktuell keine Lieferstationen.'; @override String get phone => 'Telefon'; @@ -277,7 +279,8 @@ class AppLocalizationsDe extends AppLocalizations { String get noTasks => 'Keine Aufgaben'; @override - String get noTasksMessage => 'Für diesen Job sind keine Aufgaben definiert.'; + String get noTasksMessage => + 'Für diesen Auftrag sind keine Aufgaben definiert.'; @override String get taskOrder => 'Reihenfolge'; @@ -498,13 +501,13 @@ class AppLocalizationsDe extends AppLocalizations { String get imageSendError => 'Bild konnte nicht gesendet werden.'; @override - String get chatTypeJob => 'Job-spezifisch'; + String get chatTypeJob => 'Auftragsbezogen'; @override String get chatTypeGeneral => 'Allgemein'; @override - String get jobNumber => 'Job-Nummer'; + String get jobNumber => 'Auftragsnummer'; @override String get messages => 'Nachrichten'; @@ -558,7 +561,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noCargoItemsMessage => - 'Für diesen Job sind keine Frachtgüter definiert.'; + 'Für diesen Auftrag sind keine Frachtgüter definiert.'; @override String get article => 'Artikel'; diff --git a/app/lib/l10n/localization_helpers.dart b/app/lib/l10n/localization_helpers.dart index caf9507..000ceef 100644 --- a/app/lib/l10n/localization_helpers.dart +++ b/app/lib/l10n/localization_helpers.dart @@ -46,5 +46,12 @@ String localizedChatTitle(BuildContext context, Chat chat) { if (chat.type == ChatType.general) { return AppLocalizations.of(context).generalMessages; } + // Older locally stored conversations still use the previous title prefix. + if (chat.title.startsWith('Job ')) { + return 'Auftrag ${chat.title.substring(4)}'; + } + if (chat.title == 'Job-Chat') { + return 'Auftrags-Chat'; + } return localizeKnownText(context, chat.title); } diff --git a/app/lib/models/chat.dart b/app/lib/models/chat.dart index 40021f2..7ac3be5 100644 --- a/app/lib/models/chat.dart +++ b/app/lib/models/chat.dart @@ -98,7 +98,7 @@ class Chat { final lastMessage = messages.isNotEmpty ? messages.last : null; return Chat( id: id, - title: 'Job $jobNumber', + title: 'Auftrag $jobNumber', receiver: receiver, type: ChatType.jobSpecific, jobId: jobId, diff --git a/app/lib/models/job.dart b/app/lib/models/job.dart index 07a111c..cf22702 100644 --- a/app/lib/models/job.dart +++ b/app/lib/models/job.dart @@ -109,7 +109,7 @@ class Job { this.assignedTo, this.location, this.additionalData, - }) : title = title ?? 'Job $jobNumber', + }) : title = title ?? 'Auftrag $jobNumber', description = description ?? 'Transport von $pickupCity nach $deliveryCity'; diff --git a/app/lib/services/chat_service.dart b/app/lib/services/chat_service.dart index 25ca319..165f492 100644 --- a/app/lib/services/chat_service.dart +++ b/app/lib/services/chat_service.dart @@ -350,12 +350,12 @@ class ChatService { String _buildJobTitle(String? jobNumber, String? jobId) { if (jobNumber != null && jobNumber.isNotEmpty) { - return 'Job $jobNumber'; + return 'Auftrag $jobNumber'; } if (jobId != null && jobId.length >= 6) { - return 'Job ${jobId.substring(0, 6).toUpperCase()}'; + return 'Auftrag ${jobId.substring(0, 6).toUpperCase()}'; } - return 'Job-Chat'; + return 'Auftrags-Chat'; } String? _determineCounterpart( diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index 90d7462..6fbf2a0 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -21,9 +21,9 @@ class NotificationService { 'Benachrichtigungen bei neuen Chat-Nachrichten'; static const String _jobChannelId = 'new_jobs'; - static const String _jobChannelName = 'Neue Jobs'; + static const String _jobChannelName = 'Neue Aufträge'; static const String _jobChannelDescription = - 'Benachrichtigungen bei neuen Job-Zuweisungen'; + 'Benachrichtigungen bei neuen Auftragszuweisungen'; int _nextId = 0; @@ -49,12 +49,15 @@ class NotificationService { await _plugin .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() + AndroidFlutterLocalNotificationsPlugin + >() ?.requestNotificationsPermission(); _initialized = true; - developer.log('NotificationService initialized', - name: 'NotificationService'); + developer.log( + 'NotificationService initialized', + name: 'NotificationService', + ); } Future showChatNotification({ @@ -87,8 +90,13 @@ class NotificationService { iOS: iosDetails, ); - await _plugin.show(_nextId++, title, body, details, - payload: 'chat:$conversationKey'); + await _plugin.show( + _nextId++, + title, + body, + details, + payload: 'chat:$conversationKey', + ); } Future showJobNotification({ diff --git a/app/lib/services/websocket_service.dart b/app/lib/services/websocket_service.dart index c11e29a..121c223 100644 --- a/app/lib/services/websocket_service.dart +++ b/app/lib/services/websocket_service.dart @@ -440,11 +440,11 @@ class WebSocketService { if (currentJobCount > 0 && jobs.length > currentJobCount) { final newCount = jobs.length - currentJobCount; NotificationService().showJobNotification( - title: 'Neue Jobs', + title: 'Neue Aufträge', body: newCount == 1 - ? 'Sie haben einen neuen Job erhalten.' - : 'Sie haben $newCount neue Jobs erhalten.', + ? 'Sie haben einen neuen Auftrag erhalten.' + : 'Sie haben $newCount neue Aufträge erhalten.', ); } @@ -597,11 +597,11 @@ class WebSocketService { // Show notification with sound for new job NotificationService().showJobNotification( - title: 'Neuer Job', + title: 'Neuer Auftrag', body: jobNumber != null && jobNumber.isNotEmpty - ? 'Job $jobNumber wurde Ihnen zugewiesen.' - : 'Sie haben einen neuen Job erhalten.', + ? 'Auftrag $jobNumber wurde Ihnen zugewiesen.' + : 'Sie haben einen neuen Auftrag erhalten.', ); // Publish event via DartMQ for UI to handle @@ -642,8 +642,8 @@ class WebSocketService { final jobNumber = message.jobNumber ?? ''; notificationTitle = jobNumber.isNotEmpty - ? 'Nachricht zu Job $jobNumber' - : 'Neue Job-Nachricht'; + ? 'Nachricht zu Auftrag $jobNumber' + : 'Neue Auftragsnachricht'; } else { notificationTitle = 'Neue Nachricht'; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c981b61..8308efc 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 # 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. -version: 0.9.23+923 +version: 0.9.24+924 environment: sdk: ^3.7.0 diff --git a/backend/pom.xml b/backend/pom.xml index 4e72fe9..ff1981f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.23 + 0.9.24 21 21 21 diff --git a/backend/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java b/backend/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java index 8d63337..cab7502 100644 --- a/backend/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java +++ b/backend/src/main/java/de/assecutor/votianlt/ai/service/AiStatisticsService.java @@ -163,13 +163,13 @@ public class AiStatisticsService { - Untersuche, erwähne oder vermute niemals Daten anderer Benutzer WICHTIG - ANTWORTSTIL: - - Der Benutzer fragt nach einer Liste von Jobs/Aufträgen + - Der Benutzer fragt nach einer Liste von Aufträgen - Gib ausschließlich ein JSON-Objekt im geforderten Format aus - Gib niemals Gedankengänge, Zwischenschritte oder Tags wie aus - Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen - Vermeide Formulierungen wie "Sie fragen", "Du fragst", "Die Frage ist", "Gewünscht ist" oder "Der Benutzer möchte wissen" - Im Feld "answer" beginnt die Antwort direkt mit dem Ergebnis - - Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Jobnummern + - Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Auftragsnummern - Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage - Maximal 1-2 Sätze - Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen @@ -803,27 +803,27 @@ public class AiStatisticsService { List jobs; if (customerFilter != null && statusFilter != null) { jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter); - context.append(String.format("**Jobs des angemeldeten Benutzers für %s mit Status %s:**\n\n", + context.append(String.format("**Aufträge des angemeldeten Benutzers für %s mit Status %s:**\n\n", customerFilter, statusFilter.getDisplayName())); } else if (customerFilter != null) { jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter); - context.append(String.format("**Jobs des angemeldeten Benutzers für %s:**\n\n", customerFilter)); + context.append(String.format("**Aufträge des angemeldeten Benutzers für %s:**\n\n", customerFilter)); } else if (statusFilter != null) { jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); - context.append(String.format("**Jobs des angemeldeten Benutzers mit Status %s:**\n\n", + context.append(String.format("**Aufträge des angemeldeten Benutzers mit Status %s:**\n\n", statusFilter.getDisplayName())); } else { jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); - context.append("**Aktuelle Jobs des angemeldeten Benutzers:**\n\n"); + context.append("**Aktuelle Aufträge des angemeldeten Benutzers:**\n\n"); } - context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size())); + context.append(String.format("Gefunden: %d Aufträge\n\n", jobs.size())); // Show job summaries (max 10) int shown = 0; for (Job job : jobs) { if (shown >= 10) { - context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10)); + context.append(String.format("... und %d weitere Aufträge\n", jobs.size() - 10)); break; } context.append( @@ -943,7 +943,7 @@ public class AiStatisticsService { job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt")) .toList(); - return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows); + return buildHtmlTable(List.of("Auftragsnummer", "Kunde", "Status"), rows); } private String buildStatusTableHtml(String currentUserId, String customerFilter) { @@ -1209,17 +1209,17 @@ public class AiStatisticsService { List jobs; if (customer != null && statusFilter != null) { jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter); - yield String.format("%d Jobs für %s mit Status %s.", jobs.size(), customer, + yield String.format("%d Aufträge für %s mit Status %s.", jobs.size(), customer, statusFilter.getDisplayName()); } else if (customer != null) { jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer); - yield String.format("%d Jobs für %s.", jobs.size(), customer); + yield String.format("%d Aufträge für %s.", jobs.size(), customer); } else if (statusFilter != null) { jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); - yield String.format("%d Jobs mit Status %s.", jobs.size(), statusFilter.getDisplayName()); + yield String.format("%d Aufträge mit Status %s.", jobs.size(), statusFilter.getDisplayName()); } else { jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); - yield String.format("%d aktuelle Jobs.", jobs.size()); + yield String.format("%d aktuelle Aufträge.", jobs.size()); } } case "status" -> { diff --git a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java index bfc6cf1..b7e03fa 100644 --- a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -6,6 +6,7 @@ import de.assecutor.votianlt.dto.ChatMessageInboundPayload; import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.SignatureTask; @@ -32,6 +33,7 @@ import lombok.extern.slf4j.Slf4j; import de.assecutor.votianlt.messaging.MessagingPublisher; import org.springframework.stereotype.Component; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -39,6 +41,7 @@ import org.springframework.data.mongodb.core.query.Update; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.bson.types.ObjectId; @@ -184,30 +187,34 @@ public class MessageController { * go to generic processing. */ public void handleTaskCompleted(Map payload, String taskType) { + handleTaskCompleted(payload, taskType, null); + } + + public void handleTaskCompleted(Map payload, String taskType, String appUserId) { String key = taskType == null ? "" : taskType.trim().toUpperCase(); switch (key) { - case "PHOTO" -> processPhotoTaskCompletion(payload); - case "CONFIRMATION" -> processConfirmationTaskCompletion(payload); - case "SIGNATURE" -> processSignatureTaskCompletion(payload); - case "TODOLIST" -> processTodoListTaskCompletion(payload); - case "BARCODE" -> processBarcodeTaskCompletion(payload); - case "COMMENT" -> processCommentTaskCompletion(payload); + case "PHOTO" -> processPhotoTaskCompletion(payload, appUserId); + case "CONFIRMATION" -> processConfirmationTaskCompletion(payload, appUserId); + case "SIGNATURE" -> processSignatureTaskCompletion(payload, appUserId); + case "TODOLIST" -> processTodoListTaskCompletion(payload, appUserId); + case "BARCODE" -> processBarcodeTaskCompletion(payload, appUserId); + case "COMMENT" -> processCommentTaskCompletion(payload, appUserId); default -> log.error("[TASK] Unknown taskType: {}", taskType); } } - private void processConfirmationTaskCompletion(Map payload) { + private void processConfirmationTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); - completeTaskWithHistory(taskId, "Bestätigung durchgeführt"); + completeTaskWithHistory(taskId, "Bestätigung durchgeführt", appUserId); } - private void processTodoListTaskCompletion(Map payload) { + private void processTodoListTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); - completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt"); + completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt", appUserId); } - private void processBarcodeTaskCompletion(Map payload) { + private void processBarcodeTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); try { var opt = taskRepository.findById(new ObjectId(taskId.toString())); @@ -226,7 +233,7 @@ public class MessageController { if (!barcodes.isEmpty()) { for (String barcodeString : barcodes) { - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString, completedBy); barcodeRepository.save(barcodeEntry); @@ -244,13 +251,13 @@ public class MessageController { extraDataSummary = "Keine Extra-Daten"; } - completeTaskWithHistory(taskId, extraDataSummary); + completeTaskWithHistory(taskId, extraDataSummary, appUserId); } catch (Exception ex) { log.error("[TASK] Barcode completion error: {}", ex.getMessage()); } } - private void processSignatureTaskCompletion(Map payload) { + private void processSignatureTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); try { var opt = taskRepository.findById(new ObjectId(taskId.toString())); @@ -267,7 +274,7 @@ public class MessageController { String signatureNote = signatureNoteObj instanceof String s ? s : null; if (signatureSvgObj instanceof String signatureSvg) { if (!signatureSvg.isBlank()) { - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg, signatureNote, completedBy); signatureRepository.save(signatureEntry); @@ -285,13 +292,13 @@ public class MessageController { extraDataSummary = "Keine Extra-Daten"; } - completeTaskWithHistory(taskId, extraDataSummary); + completeTaskWithHistory(taskId, extraDataSummary, appUserId); } catch (Exception ex) { log.error("[TASK] Signature completion error: {}", ex.getMessage()); } } - private void processPhotoTaskCompletion(Map payload) { + private void processPhotoTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); try { var opt = taskRepository.findById(new ObjectId(taskId.toString())); @@ -310,7 +317,7 @@ public class MessageController { if (!photos.isEmpty()) { for (String photoString : photos) { - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy); photoRepository.save(photoEntry); } @@ -325,13 +332,13 @@ public class MessageController { extraDataSummary = "Keine Extra-Daten"; } - completeTaskWithHistory(taskId, extraDataSummary); + completeTaskWithHistory(taskId, extraDataSummary, appUserId); } catch (Exception ex) { log.error("[TASK] Photo completion error: {}", ex.getMessage()); } } - private void processCommentTaskCompletion(Map payload) { + private void processCommentTaskCompletion(Map payload, String appUserId) { Object taskId = payload.get("taskId"); try { var opt = taskRepository.findById(new ObjectId(taskId.toString())); @@ -345,7 +352,7 @@ public class MessageController { if (extra instanceof Map extraData) { Object commentTextObj = extraData.get("commentText"); if (commentTextObj instanceof String commentText && !commentText.isBlank()) { - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy); commentRepository.save(commentEntry); extraDataSummary = "Kommentar: " + commentText; @@ -356,13 +363,13 @@ public class MessageController { extraDataSummary = "Kommentar abgegeben"; } - completeTaskWithHistory(taskId, extraDataSummary); + completeTaskWithHistory(taskId, extraDataSummary, appUserId); } catch (Exception ex) { log.error("[TASK] Comment completion error: {}", ex.getMessage()); } } - private void completeTaskWithHistory(Object tid, String extraDataSummary) { + private void completeTaskWithHistory(Object tid, String extraDataSummary, String appUserId) { String taskIdStr = tid.toString(); try { ObjectId taskId = new ObjectId(taskIdStr); @@ -371,6 +378,7 @@ public class MessageController { return; } BaseTask task = opt.get(); + task.setCompletedBy(resolveTaskActor(task, appUserId)); task.setCompleted(true); task.setCompletedAt(LocalDateTime.now()); taskRepository.save(task); @@ -385,9 +393,9 @@ public class MessageController { try { String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType; - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName, - extraDataSummary); + extraDataSummary, task.getDescription() != null ? task.getDescription() : ""); } catch (Exception e) { log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId, e.getMessage(), e); @@ -396,7 +404,7 @@ public class MessageController { // Send email notification for task completion try { String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; - String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; + String completedBy = resolveTaskActor(task, appUserId); emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy); // Job completion is no longer auto-triggered by task completion. // It is now driven by explicit station_completed messages from the app @@ -412,43 +420,17 @@ public class MessageController { } } - private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) { - try { - var allTasks = taskAssignmentService.findTasksForJob(jobId); - if (allTasks.isEmpty()) { - return; - } - - var mandatoryTasks = allTasks.stream().filter(task -> !task.isOptional()).toList(); - if (mandatoryTasks.isEmpty()) { - return; - } - boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted()); - - if (allCompleted) { - if (!updateJobStatusToCompleted(jobId)) { - return; - } - try { - emailService.sendJobCompletionNotification(jobId, completedBy); - } catch (Exception e) { - log.error("[JOB] Could not send job completion notification for job {}: {}", jobId, e.getMessage(), - e); - } - } else { - List openTaskIds = mandatoryTasks.stream().filter(task -> !task.isCompleted()) - .map(task -> task.getIdAsString()).toList(); - log.info("[JOB] Job {} not completed yet, {} of {} mandatory tasks still open: {}", jobId, - openTaskIds.size(), mandatoryTasks.size(), openTaskIds); - } - } catch (Exception e) { - log.error("[JOB] Completion check failed for job {}: {}", jobId, e.getMessage(), e); + private String resolveTaskActor(BaseTask task, String appUserId) { + if (appUserId != null && !appUserId.isBlank()) { + return appUserId; } + return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; } private boolean updateJobStatusToCompleted(ObjectId jobId) { try { - Query query = Query.query(Criteria.where("_id").is(jobId).and("status").ne(JobStatus.COMPLETED)); + Query query = Query.query(Criteria.where("_id").is(jobId) + .and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED)); Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now()); boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0; if (completed) { @@ -472,8 +454,9 @@ public class MessageController { * "hasIncompleteOptionalTasks": false * } * - * The job is marked as completed once this message is received and all - * mandatory tasks across all stations are completed. + * Intermediate stations send a station notification. The final route station + * completes the job once all mandatory tasks are done, sending only the job + * notification. Confirmations are recorded atomically to suppress duplicates. */ public void handleStationCompleted(String appUserId, Map payload) { try { @@ -493,19 +476,76 @@ public class MessageController { return; } - ObjectId jobId = jobOpt.get().getId(); + Job job = jobOpt.get(); + ObjectId jobId = job.getId(); + int stationOrder = Integer.parseInt(String.valueOf(payload.get("stationOrder"))); + List stations = job.getDeliveryStations() != null + ? job.getDeliveryStations().stream().filter(Objects::nonNull).toList() : List.of(); + DeliveryStation station = stations.stream() + .filter(candidate -> candidate.getStationOrder() == stationOrder).findFirst().orElse(null); + if (station == null && !(stations.isEmpty() && stationOrder == 0)) { + log.warn("[STATION] Unknown station {} for job {}", stationOrder, jobId); + return; + } + + var tasks = taskAssignmentService.findTasksForJob(jobId); + boolean stationHasOpenMandatoryTasks = tasks.stream() + .filter(task -> belongsToStation(task, station, stationOrder)) + .anyMatch(task -> !task.isOptional() && !task.isCompleted()); + if (stationHasOpenMandatoryTasks) { + log.info("[STATION] Station {} of job {} still has open mandatory tasks", stationOrder, jobId); + return; + } + String completedBy = appUserId != null ? appUserId : "Unknown"; - log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber, - payload.get("stationOrder")); + stationOrder); - checkAndHandleJobCompletion(jobId, completedBy); + Query query = Query.query(Criteria.where("_id").is(jobId) + .and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED) + .and("completedStationOrders").ne(stationOrder)); + Update update = new Update().addToSet("completedStationOrders", stationOrder) + .set("updatedAt", LocalDateTime.now()); + Job confirmedJob = mongoTemplate.findAndModify(query, update, + FindAndModifyOptions.options().returnNew(true), Job.class); + boolean firstConfirmation = confirmedJob != null; + if (confirmedJob == null) { + // A replay may finish a previously interrupted job transition, + // but must never send another station notification. + confirmedJob = jobRepository.findById(jobId).orElse(null); + if (confirmedJob == null || confirmedJob.getStatus() == JobStatus.COMPLETED + || confirmedJob.getStatus() == JobStatus.CANCELLED + || confirmedJob.getCompletedStationOrders() == null + || !confirmedJob.getCompletedStationOrders().contains(stationOrder)) { + return; + } + } + + int lastStationOrder = stations.stream().mapToInt(DeliveryStation::getStationOrder).max().orElse(0); + boolean lastStationConfirmed = confirmedJob.getCompletedStationOrders().contains(lastStationOrder); + boolean allMandatoryTasksCompleted = tasks.stream().filter(task -> !task.isOptional()) + .allMatch(BaseTask::isCompleted); + + if (lastStationConfirmed && allMandatoryTasksCompleted) { + if (updateJobStatusToCompleted(jobId)) { + emailService.sendJobCompletionNotification(jobId, completedBy); + } + } else if (firstConfirmation && stationOrder != lastStationOrder) { + emailService.sendStationCompletionNotification(jobId, stationOrder, completedBy); + } jobUpdateBroadcaster.broadcast(jobId); } catch (Exception e) { - log.error("[STATION] Error handling station_completed: {}", e.getMessage()); + log.error("[STATION] Error handling station_completed: {}", e.getMessage(), e); } } + private boolean belongsToStation(BaseTask task, DeliveryStation station, int stationOrder) { + if (station != null && station.getStationId() != null && task.getStationId() != null) { + return station.getStationId().equals(task.getStationId()); + } + return (task.getStationOrder() != null ? task.getStationOrder() : 0) == stationOrder; + } + /** * Handle incoming message from a client via WebSocket. Client sends to * /server/message with payload: { "content": "message payload", "contentType": diff --git a/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java b/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java index 0d3b581..d7f1973 100644 --- a/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java +++ b/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java @@ -66,12 +66,12 @@ public class MessagingConfig { webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> { handlePayload(payload, payloadMap -> { String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null; - messageController.handleTaskCompleted(payloadMap, taskType); + messageController.handleTaskCompleted(payloadMap, taskType, appUserId); }); }); - // Station completion handler — marks a job as completed once all mandatory - // tasks have been finished and the app confirms the station is done. + // Station completion handler — notifies for intermediate stations and completes + // the job after the final station and all mandatory tasks are done. webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> { handlePayload(payload, payloadMap -> { messageController.handleStationCompleted(appUserId, payloadMap); diff --git a/backend/src/main/java/de/assecutor/votianlt/model/Job.java b/backend/src/main/java/de/assecutor/votianlt/model/Job.java index 4767ac2..06bf5da 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/Job.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/Job.java @@ -168,6 +168,9 @@ public class Job { @Field("delivery_stations") private List deliveryStations = new ArrayList<>(); + @Field("completed_station_orders") + private List completedStationOrders = new ArrayList<>(); + /** * Returns the ObjectId as string for JSON serialization. This ensures that the * job id is returned as a string when jobs are retrieved via API. diff --git a/backend/src/main/java/de/assecutor/votianlt/model/JobHistory.java b/backend/src/main/java/de/assecutor/votianlt/model/JobHistory.java index 711c101..ee218b3 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/JobHistory.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/JobHistory.java @@ -49,6 +49,10 @@ public class JobHistory { */ private String details; + /** Task reference and description as recorded when the event occurred. */ + private ObjectId taskId; + private String taskDescription; + /** * Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.) */ @@ -96,4 +100,4 @@ public class JobHistory { public String getJobIdAsString() { return jobId != null ? jobId.toHexString() : null; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java b/backend/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java index b11ae83..dba7f07 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/service/AddJobService.java @@ -172,7 +172,7 @@ public class AddJobService { * Generiert eine eindeutige Auftragsnummer */ private String generateJobNumber() { - String prefix = "JOB"; + String prefix = "AUFTRAG"; String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); // Zähle Aufträge des aktuellen Tages diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java index c3de2cc..d961313 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobHistoryView.java @@ -209,6 +209,14 @@ public class JobHistoryView extends Main implements HasUrlParameter, Has cardContent.add(description); } + String taskDescription = jobHistoryService.getTaskDescription(entry); + if (!taskDescription.isBlank()) { + Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + taskDescription); + instructions.addClassName("timeline-description"); + instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere"); + cardContent.add(instructions); + } + // Photo preview for photo tasks if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null && entry.getDetails().contains("Task-Typ: PHOTO")) { @@ -239,9 +247,10 @@ public class JobHistoryView extends Main implements HasUrlParameter, Has } } - // Changed by (if available) - if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) { - Span changedBy = new Span(getTranslation("jobhistory.changedby", entry.getChangedBy())); + // Author of this history entry. + String createdBy = jobHistoryService.getCreatedByName(entry); + if (createdBy != null && !createdBy.isBlank()) { + Span changedBy = new Span(getTranslation("jobhistory.changedby", createdBy)); changedBy.getStyle().set("color", "var(--lumo-secondary-text-color)") .set("font-size", "var(--lumo-font-size-xs)").set("margin-top", "var(--lumo-space-xs)") .set("display", "block"); @@ -260,7 +269,7 @@ public class JobHistoryView extends Main implements HasUrlParameter, Has if (entry.getChangeType() == JobHistoryType.CREATE) { return getTranslation("jobhistory.entry.create.reason"); } - return entry.getReason() != null ? entry.getReason() : ""; + return updateOrderTerminology(entry.getReason()); } private String getLocalizedDescription(JobHistory entry) { @@ -270,7 +279,14 @@ public class JobHistoryView extends Main implements HasUrlParameter, Has if (entry.getChangeType() == JobHistoryType.CREATE) { return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription())); } - return entry.getDescription(); + return entry.getChangeType() == JobHistoryType.UPDATE || entry.getChangeType() == JobHistoryType.ASSIGNMENT + ? updateOrderTerminology(entry.getDescription()) : entry.getDescription(); + } + + private String updateOrderTerminology(String text) { + return text == null ? "" : text.replace("Job erstellt", "Auftrag erstellt") + .replace("Job aktualisiert", "Auftrag aktualisiert").replace("Job-Daten", "Auftragsdaten") + .replace("Job zugewiesen", "Auftrag zugewiesen").replace("Job-Zuweisung", "Auftragszuweisung"); } private String extractDescriptionValue(String description) { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java index 592b1cb..f1b0d3d 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/JobSummaryView.java @@ -555,6 +555,12 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has } private String summarizeDeliveryTask(BaseTask task) { + String summary = summarizeDeliveryTaskType(task); + String description = trimToNull(task.getDescription()); + return description != null && !summary.contains(description) ? summary + " – " + description : summary; + } + + private String summarizeDeliveryTaskType(BaseTask task) { if (task instanceof ConfirmationTask confirmationTask) { String buttonText = trimToNull(confirmationTask.getButtonText()); if (buttonText != null) { @@ -1140,6 +1146,12 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has } dialogContent.add(statusSpan); + if (task.getDescription() != null && !task.getDescription().isBlank()) { + Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + task.getDescription()); + instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere"); + dialogContent.add(instructions); + } + // Task-specific details addTaskSpecificDetails(dialogContent, task); @@ -1150,7 +1162,8 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); } if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { - dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy())); + dialogContent.add(new Span("Abgeschlossen von: " + + jobHistoryService.resolveUserName(task.getCompletedBy()))); } } @@ -1374,6 +1387,12 @@ public class JobSummaryView extends Main implements HasUrlParameter, Has taskDescription.addClassName("job-task-description"); taskContent.add(headerRow, taskDescription); + if (task.getDescription() != null && !task.getDescription().isBlank()) { + Span instructions = new Span(task.getDescription()); + instructions.addClassName("job-task-description"); + instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere"); + taskContent.add(instructions); + } // Status indicator Div statusIndicator = new Div(); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java index 8472277..9197001 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -1,6 +1,7 @@ package de.assecutor.votianlt.service; import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.repository.AppUserRepository; @@ -20,6 +21,8 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @RequiredArgsConstructor @@ -77,7 +80,7 @@ public class EmailService { message.setFrom(smtpUsername); message.setTo(user.getEmail()); message.setSubject( - "Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); + "Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId())); String fullName = buildFullName(user); String taskTypeName = getTaskTypeDisplayName(taskType); @@ -85,7 +88,7 @@ public class EmailService { StringBuilder body = new StringBuilder(); body.append("Hallo ").append(fullName).append(",\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("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); if (job.getDeliveryCompany() != null) { body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); } @@ -195,6 +198,69 @@ public class EmailService { }; } + @Async + public void sendStationCompletionNotification(ObjectId jobId, int stationOrder, String completedBy) { + try { + Optional jobOpt = jobRepository.findById(jobId); + if (jobOpt.isEmpty()) { + log.warn("Job not found for station completion notification: {}", jobId); + return; + } + Job job = jobOpt.get(); + DeliveryStation station = job.getDeliveryStations() == null ? null + : job.getDeliveryStations().stream().filter(candidate -> candidate != null + && candidate.getStationOrder() == stationOrder).findFirst().orElse(null); + if (station == null) { + log.warn("Station {} not found for completion notification for job {}", stationOrder, jobId); + return; + } + Optional userOpt = userRepository.findById(new ObjectId(job.getCreatedBy())); + if (userOpt.isEmpty() || userOpt.get().getEmail() == null || userOpt.get().getEmail().isBlank()) { + log.warn("Recipient missing for station completion notification for job {}", jobId); + return; + } + User user = userOpt.get(); + String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : jobId.toHexString(); + String completedByName = findAppUserById(completedBy).map(this::buildAppUserName) + .orElseGet(() -> resolveCompletedByName(job, completedBy)); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(smtpUsername); + message.setTo(user.getEmail()); + message.setSubject("Abgeschlossene Station - " + jobNumber + " - Station " + (stationOrder + 1)); + StringBuilder body = new StringBuilder(); + body.append("Hallo ").append(buildFullName(user)).append(",\n\n"); + body.append("die folgende Station wurde abgeschlossen:\n\n"); + body.append("Auftrag: ").append(jobNumber).append("\n"); + body.append("Station: ").append(stationOrder + 1).append("\n"); + appendStationAddressLine(body, station.getCompany()); + appendStationAddressLine(body, joinAddressParts(station.getFirstName(), station.getLastName())); + appendStationAddressLine(body, joinAddressParts(station.getStreet(), station.getHouseNumber())); + appendStationAddressLine(body, station.getAddressAddition()); + appendStationAddressLine(body, joinAddressParts(station.getZip(), station.getCity())); + body.append("Abgeschlossen von: ").append(completedByName).append("\n\n"); + body.append("Mit freundlichen Grüßen,\nIhr Votianlt-System"); + message.setText(body.toString()); + mailSender.send(message); + log.info("Station completion notification sent to {} for job {} station {}", user.getEmail(), jobNumber, + stationOrder); + } catch (Exception e) { + log.error("Failed to send station completion notification for job {} station {}: {}", jobId, stationOrder, + e.getMessage(), e); + } + } + + private void appendStationAddressLine(StringBuilder body, String line) { + if (line != null && !line.isBlank()) { + body.append(line.trim()).append("\n"); + } + } + + private String joinAddressParts(String... parts) { + return Stream.of(parts).filter(part -> part != null && !part.isBlank()).map(String::trim) + .collect(Collectors.joining(" ")); + } + @Async public void sendJobCompletionNotification(ObjectId jobId, String completedBy) { try { @@ -236,7 +302,7 @@ public class EmailService { message.setFrom(smtpUsername); message.setTo(user.getEmail()); message.setSubject( - "Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); + "Auftrag abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId())); String fullName = buildFullName(user); @@ -246,8 +312,8 @@ public class EmailService { StringBuilder body = new StringBuilder(); body.append("Hallo ").append(fullName).append(",\n\n"); - body.append("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen:\n\n"); - body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); + body.append("alle Aufgaben für den folgenden Auftrag wurden erfolgreich abgeschlossen:\n\n"); + body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); if (job.getDeliveryCompany() != null) { body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); } @@ -270,7 +336,7 @@ public class EmailService { body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\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 Auftrag ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n"); body.append("Mit freundlichen Grüßen,\n"); body.append("Ihr Votianlt-System"); @@ -325,7 +391,7 @@ public class EmailService { message.setFrom(smtpUsername); message.setTo(user.getEmail()); message.setSubject( - "Neuer Job erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId())); + "Neuer Auftrag erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId())); String fullName = buildFullName(user); @@ -335,8 +401,8 @@ public class EmailService { StringBuilder body = new StringBuilder(); body.append("Hallo ").append(fullName).append(",\n\n"); - body.append("ein neuer Job wurde erfolgreich erstellt:\n\n"); - body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); + body.append("ein neuer Auftrag wurde erfolgreich erstellt:\n\n"); + body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) { body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n"); @@ -374,7 +440,7 @@ public class EmailService { : "Unbekannt") .append("\n\n"); - body.append("Der Job ist nun im System verfügbar und kann bearbeitet werden.\n\n"); + body.append("Der Auftrag ist nun im System verfügbar und kann bearbeitet werden.\n\n"); body.append("Mit freundlichen Grüßen,\n"); body.append("Ihr Votianlt-System"); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java b/backend/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java index bdcfac9..789ffd1 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/JobHistoryService.java @@ -5,12 +5,20 @@ import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobHistory; import de.assecutor.votianlt.model.JobHistoryType; import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.JobHistoryRepository; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import de.assecutor.votianlt.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Service @Slf4j @@ -18,10 +26,20 @@ public class JobHistoryService { private final JobHistoryRepository jobHistoryRepository; private final ObjectMapper objectMapper; + private final AppUserRepository appUserRepository; + private final UserRepository userRepository; + private final TaskRepository taskRepository; + private final JobRepository jobRepository; - public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper) { + public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper, + AppUserRepository appUserRepository, UserRepository userRepository, TaskRepository taskRepository, + JobRepository jobRepository) { this.jobHistoryRepository = jobHistoryRepository; this.objectMapper = objectMapper; + this.appUserRepository = appUserRepository; + this.userRepository = userRepository; + this.taskRepository = taskRepository; + this.jobRepository = jobRepository; } /** @@ -29,9 +47,9 @@ public class JobHistoryService { */ public void logJobCreation(Job job, String createdBy) { try { - JobHistory history = new JobHistory(job.getId(), "Job erstellt", - "Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"), - createdBy, JobHistoryType.CREATE, null, "Job erstellt"); + JobHistory history = new JobHistory(job.getId(), "Auftrag erstellt", + "Neuer Auftrag wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"), + createdBy, JobHistoryType.CREATE, null, "Auftrag erstellt"); if (job.getDeliveryCompany() != null) { history.setDetails("Kunde: " + job.getDeliveryCompany()); @@ -70,7 +88,7 @@ public class JobHistoryService { try { String description = generateUpdateDescription(oldJob, newJob); - JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Job aktualisiert", + JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Auftrag aktualisiert", description, changedBy, JobHistoryType.UPDATE, serializeJobForComparison(oldJob), serializeJobForComparison(newJob)); @@ -93,6 +111,11 @@ public class JobHistoryService { */ public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy, String taskDisplayName, String extraDataSummary) { + logTaskCompletion(jobId, taskType, taskId, completedBy, taskDisplayName, extraDataSummary, null); + } + + public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy, + String taskDisplayName, String extraDataSummary, String taskDescription) { try { String taskName = taskDisplayName != null ? taskDisplayName : taskType; String description = String.format("Aufgabe abgeschlossen: %s", taskName); @@ -101,8 +124,10 @@ public class JobHistoryService { description += " - " + extraDataSummary; } - JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, completedBy, + JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, resolveUserName(completedBy), JobHistoryType.TASK_COMPLETED, "In Bearbeitung", "Abgeschlossen"); + history.setTaskId(ObjectId.isValid(taskId) ? new ObjectId(taskId) : null); + history.setTaskDescription(taskDescription); // Detaillierte Informationen in details speichern StringBuilder details = new StringBuilder(); @@ -132,11 +157,11 @@ public class JobHistoryService { try { String description; if (oldAssignee == null && newAssignee != null) { - description = "Job zugewiesen an: " + newAssignee; + description = "Auftrag zugewiesen an: " + newAssignee; } else if (oldAssignee != null && newAssignee == null) { - description = "Job-Zuweisung entfernt von: " + oldAssignee; + description = "Auftragszuweisung entfernt von: " + oldAssignee; } else { - description = String.format("Job-Zuweisung geändert von %s zu %s", oldAssignee, newAssignee); + description = String.format("Auftragszuweisung geändert von %s zu %s", oldAssignee, newAssignee); } JobHistory history = new JobHistory(job.getId(), "Zuweisung geändert", description, changedBy, @@ -177,6 +202,85 @@ public class JobHistoryService { return jobHistoryRepository.countByJobId(jobId); } + public String getTaskDescription(JobHistory entry) { + if (entry.getTaskDescription() != null) { + return entry.getTaskDescription(); + } + return findHistoryTask(entry).map(BaseTask::getDescription).orElse(""); + } + + public String getCreatedByName(JobHistory entry) { + if (!isUnknownUser(entry.getChangedBy())) { + return resolveUserName(entry.getChangedBy()); + } + if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED) { + Optional taskActor = findHistoryTask(entry).map(BaseTask::getCompletedBy) + .filter(actor -> !isUnknownUser(actor)); + if (taskActor.isPresent()) { + return resolveUserName(taskActor.get()); + } + // Old events did not record the authenticated actor. Identify the + // current assignee explicitly rather than presenting it as certain. + if (entry.getJobId() != null) { + Optional assignedUser = jobRepository.findById(entry.getJobId()).map(Job::getAppUser) + .filter(actor -> !isUnknownUser(actor)); + if (assignedUser.isPresent()) { + return resolveUserName(assignedUser.get()) + " (zugewiesener App-Nutzer)"; + } + } + } + return "Unbekannt"; + } + + public String resolveUserName(String actor) { + if (isUnknownUser(actor)) { + return "Unbekannt"; + } + if (!ObjectId.isValid(actor)) { + return actor; + } + ObjectId id = new ObjectId(actor); + var appUser = appUserRepository.findById(id); + if (appUser.isPresent()) { + var user = appUser.get(); + return firstNonBlank(joinName(user.getVorname(), user.getNachname()), user.getBezeichnung(), + user.getEmail(), actor); + } + return userRepository.findById(id) + .map(user -> firstNonBlank(joinName(user.getTitle(), user.getFirstname(), user.getName()), + user.getEmail(), actor)) + .orElse(actor); + } + + private Optional findHistoryTask(JobHistory entry) { + if (entry.getChangeType() != JobHistoryType.TASK_COMPLETED) { + return Optional.empty(); + } + ObjectId taskId = entry.getTaskId(); + if (taskId == null && entry.getDetails() != null) { + var matcher = java.util.regex.Pattern.compile("(?:^|,\\s*)Task-ID: ([0-9a-fA-F]{24})(?:,|$)") + .matcher(entry.getDetails()); + if (matcher.find()) { + taskId = new ObjectId(matcher.group(1)); + } + } + return taskId != null ? taskRepository.findById(taskId) : Optional.empty(); + } + + private boolean isUnknownUser(String actor) { + return actor == null || actor.isBlank() || "Unknown".equalsIgnoreCase(actor) + || "Unkown".equalsIgnoreCase(actor) || "Unbekannt".equalsIgnoreCase(actor); + } + + private String joinName(String... parts) { + return Stream.of(parts).filter(part -> part != null && !part.isBlank()).map(String::trim) + .collect(Collectors.joining(" ")); + } + + private String firstNonBlank(String... values) { + return Stream.of(values).filter(value -> value != null && !value.isBlank()).findFirst().orElse(""); + } + // Helper methods private String formatStatus(JobStatus status) { @@ -197,7 +301,7 @@ public class JobHistoryService { } private String generateUpdateDescription(Job oldJob, Job newJob) { - StringBuilder description = new StringBuilder("Job-Daten aktualisiert"); + StringBuilder description = new StringBuilder("Auftragsdaten aktualisiert"); if (oldJob == null) { return description.toString(); @@ -241,4 +345,4 @@ public class JobHistoryService { return "Serialization failed"; } } -} \ No newline at end of file +} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 8800716..a087813 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -260,7 +260,7 @@ page.title.statistics=Statistiken page.title.password.forget=Passwort zurücksetzen page.title.invoices=Rechnungen page.title.appusers=App-Nutzer -page.title.job.history=Job Historie +page.title.job.history=Auftrags-Historie page.title.message.history=Nachrichtenverlauf page.title.myinvoices=Rechnungen page.title.job.create=Neuen Auftrag anlegen @@ -583,11 +583,11 @@ addjob.validation.address.correct=Adresse korrigieren # Job Summary jobsummary.title=Zusammenfassung -jobsummary.error.noid=Keine Job-ID angegeben -jobsummary.error.invalidid=Ungültige Job-ID Format: {0} -jobsummary.error.notfound=Job mit ID {0} nicht gefunden +jobsummary.error.noid=Keine Auftrags-ID angegeben +jobsummary.error.invalidid=Ungültiges Format der Auftrags-ID: {0} +jobsummary.error.notfound=Auftrag mit ID {0} nicht gefunden jobsummary.button.sendmessage=Nachricht senden -jobsummary.button.jobhistory=Job Historie +jobsummary.button.jobhistory=Auftrags-Historie jobsummary.button.complete=Auftrag manuell abschließen jobsummary.dialog.complete.title=Auftrag abschließen jobsummary.dialog.complete.text=Möchten Sie den Auftrag {0} manuell abschließen? @@ -669,8 +669,8 @@ jobs.notification.delete.error=Fehler beim Löschen: {0} # Create Invoice createinvoice.title=Rechnung erstellen \u2013 Auftrag {0} -createinvoice.error.invalidid=Ungültige Job-ID -createinvoice.error.notfound=Job nicht gefunden +createinvoice.error.invalidid=Ungültige Auftrags-ID +createinvoice.error.notfound=Auftrag nicht gefunden createinvoice.button.create=Rechnung erstellen createinvoice.section.job=Auftragsdetails createinvoice.section.route=Streckeninfo @@ -986,7 +986,7 @@ csv.header.customer=Auftraggeber csv.header.jobnumber=Auftragsnummer csv.header.jobdate=Auftragsdatum csv.header.destination=Zielort -csv.filename=jobs.csv +csv.filename=auftraege.csv # DatePicker I18n datepicker.month.januar=Januar @@ -1022,15 +1022,15 @@ jobhistory.status.pickedup=Abgeholt jobhistory.status.intransit=Unterwegs jobhistory.status.delivered=Zugestellt jobhistory.image.alt=Vergrößertes Foto -jobhistory.title=Jobhistorie -jobhistory.header=Jobhistorie für {0} +jobhistory.title=Auftrags-Historie +jobhistory.header=Auftrags-Historie für {0} jobhistory.info.customer=Kunde: {0} jobhistory.info.createdat=Erstellt am: {0} jobhistory.info.status=Status: {0} jobhistory.count={0} Einträge in der Historie -jobhistory.changedby=Geändert von: {0} -jobhistory.entry.create.reason=Job erstellt -jobhistory.entry.create.description=Neuer Job wurde erstellt: {0} +jobhistory.changedby=Erstellt von: {0} +jobhistory.entry.create.reason=Auftrag erstellt +jobhistory.entry.create.description=Neuer Auftrag wurde erstellt: {0} # Version version.label=Version diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 8100b37..bd28f59 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -870,7 +870,7 @@ jobhistory.info.customer=Klient: {0} jobhistory.info.createdat=Loodud: {0} jobhistory.info.status=Olek: {0} jobhistory.count={0} kirjet ajaloos -jobhistory.changedby=Muutnud: {0} +jobhistory.changedby=Loonud: {0} jobhistory.entry.create.reason=T\u00f6\u00f6 loodud jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0} version.label=Versioon diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 08aa24a..78ec214 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -1028,7 +1028,7 @@ jobhistory.info.customer=Customer: {0} jobhistory.info.createdat=Created on: {0} jobhistory.info.status=Status: {0} jobhistory.count={0} entries in history -jobhistory.changedby=Changed by: {0} +jobhistory.changedby=Created by: {0} jobhistory.entry.create.reason=Job Created jobhistory.entry.create.description=New job was created: {0} diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 779b2bf..cead994 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=Cliente: {0} jobhistory.info.createdat=Creado el: {0} jobhistory.info.status=Estado: {0} jobhistory.count={0} entradas en el historial -jobhistory.changedby=Modificado por: {0} +jobhistory.changedby=Creado por: {0} jobhistory.entry.create.reason=Pedido creado jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0} diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index ae4aa40..54da920 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=Client : {0} jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0} jobhistory.info.status=Statut : {0} jobhistory.count={0} entr\u00e9es dans l'historique -jobhistory.changedby=Modifi\u00e9 par : {0} +jobhistory.changedby=Créé par : {0} jobhistory.entry.create.reason=Mission cr\u00e9\u00e9e jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0} diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index 26a9747..70db3d0 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -961,7 +961,7 @@ jobhistory.info.customer=Klientas: {0} jobhistory.info.createdat=Sukurta: {0} jobhistory.info.status=Būsena: {0} jobhistory.count={0} įrašų istorijoje -jobhistory.changedby=Pakeitė: {0} +jobhistory.changedby=Sukūrė: {0} jobhistory.entry.create.reason=Užsakymas sukurtas jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0} diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 555018f..3d8160a 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=Klients: {0} jobhistory.info.createdat=Izveidots: {0} jobhistory.info.status=Statuss: {0} jobhistory.count={0} ieraksti vēsturē -jobhistory.changedby=Mainīja: {0} +jobhistory.changedby=Izveidoja: {0} jobhistory.entry.create.reason=Uzdevums izveidots jobhistory.entry.create.description=Izveidots jauns uzdevums: {0} diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index a2d3d90..a10ac82 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=Klient: {0} jobhistory.info.createdat=Utworzono dnia: {0} jobhistory.info.status=Status: {0} jobhistory.count={0} wpis\u00f3w w historii -jobhistory.changedby=Zmienione przez: {0} +jobhistory.changedby=Utworzone przez: {0} jobhistory.entry.create.reason=Zlecenie utworzone jobhistory.entry.create.description=Utworzono nowe zlecenie: {0} diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 46a1708..4e1ee95 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=Клиент: {0} jobhistory.info.createdat=Создано: {0} jobhistory.info.status=Статус: {0} jobhistory.count={0} записей в истории -jobhistory.changedby=Изменено: {0} +jobhistory.changedby=Создано: {0} jobhistory.entry.create.reason=Заказ создан jobhistory.entry.create.description=Создан новый заказ: {0} diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index e72de9c..5e06820 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -959,7 +959,7 @@ jobhistory.info.customer=M\u00fc\u015fteri: {0} jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0} jobhistory.info.status=Durum: {0} jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t -jobhistory.changedby=De\u011fi\u015ftiren: {0} +jobhistory.changedby=Oluşturan: {0} jobhistory.entry.create.reason=\u0130\u015f olu\u015fturuldu jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0} diff --git a/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java b/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java index 18ac8a9..0d0eb0b 100644 --- a/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java +++ b/backend/src/test/java/de/assecutor/votianlt/controller/StationCompletionTest.java @@ -3,13 +3,17 @@ package de.assecutor.votianlt.controller; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.mongodb.client.result.UpdateResult; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.JobStatus; +import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.ConfirmationTask; import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.service.EmailService; @@ -23,10 +27,13 @@ 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -48,65 +55,242 @@ class StationCompletionTest { private MessageController controller; private final ObjectId jobId = new ObjectId(); - private ConfirmationTask mandatoryTask; - private Map payload; + private Job job; @BeforeEach void setUp() { - Job job = new Job(); + job = new Job(); job.setId(jobId); - job.setJobNumber("JOB-001"); + job.setJobNumber("AUFTRAG20260929001"); + job.setDeliveryStations(List.of(station(0), station(1))); when(jobRepository.findById(jobId)).thenReturn(Optional.of(job)); - mandatoryTask = new ConfirmationTask("Lieferung bestätigen"); - mandatoryTask.setCompleted(true); - when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask)); - payload = Map.of("jobId", jobId.toHexString(), "jobNumber", "JOB-001", "stationOrder", 0); } @Test - void repeatedStationCompletionSendsOnlyOneEmail() { + void intermediateStationSendsOnlyStationEmailEvenWhenAllTasksAreDone() { + tasks(task(0, true), task(1, true)); + confirmOnce(0); + + complete(0); + + verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1"); + verifyNoMoreInteractions(emailService); + verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class)); + } + + @Test + void finalStationSendsOnlyJobEmail() { + tasks(task(0, true), task(1, true)); + confirmOnce(1); + allowJobCompletion(); + + complete(1); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void singleStationWithUnfinishedOptionalTaskSendsOnlyJobEmail() { + job.setDeliveryStations(List.of(station(0))); + ConfirmationTask optional = task(0, false); + optional.setOptional(true); + tasks(task(0, true), optional); + confirmOnce(0); + allowJobCompletion(); + + complete(0); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void legacyJobWithoutEmbeddedStationsStillCompletes() { + job.setDeliveryStations(null); + tasks(task(0, true)); + confirmOnce(0); + allowJobCompletion(); + + complete(0); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void repeatedIntermediateStationSendsOnlyOneEmail() { + tasks(task(0, true), task(1, false)); + confirmOnce(0); + + complete(0); + complete(0); + + verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void repeatedFinalStationSendsOnlyOneJobEmail() { + tasks(task(0, true), task(1, true)); + confirmOnce(1); when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) .thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null)); - controller.handleStationCompleted("driver-1", payload); - controller.handleStationCompleted("driver-1", payload); + complete(1); + complete(1); verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); } @Test - void alreadyCompletedJobDoesNotSendAnotherEmail() { + void replayCanFinishInterruptedJobCompletionWithoutStationEmail() { + tasks(task(0, true), task(1, true)); + confirmOnce(1); when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) - .thenReturn(UpdateResult.acknowledged(0, 0L, null)); + .thenReturn(UpdateResult.acknowledged(0, 0L, null), UpdateResult.acknowledged(1, 1L, null)); - controller.handleStationCompleted("driver-1", payload); + complete(1); + complete(1); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @ParameterizedTest + @EnumSource(value = JobStatus.class, names = { "COMPLETED", "CANCELLED" }) + void closedJobDoesNotSendAnotherEmail(JobStatus status) { + job.setStatus(status); + job.setCompletedStationOrders(List.of(0, 1)); + tasks(task(0, true), task(1, true)); + + complete(1); verifyNoInteractions(emailService); - ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); - verify(mongoTemplate).updateFirst(query.capture(), any(Update.class), eq(Job.class)); - assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId) - .containsEntry("status", new Document("$ne", JobStatus.COMPLETED)); + verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class)); } @Test - void incompleteMandatoryTaskPreventsJobCompletion() { - mandatoryTask.setCompleted(false); + void incompleteMandatoryTaskPreventsStationCompletion() { + tasks(task(0, false), task(1, true)); - controller.handleStationCompleted("driver-1", payload); + complete(0); verifyNoInteractions(mongoTemplate, emailService); } @Test - void unfinishedOptionalTaskDoesNotPreventExplicitCompletion() { - ConfirmationTask optionalTask = new ConfirmationTask("Zusätzliche Bestätigung"); - optionalTask.setOptional(true); - when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask, optionalTask)); - when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) - .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + void stationIdTakesPrecedenceOverStaleTaskOrder() { + ConfirmationTask openTask = task(1, false); + openTask.setStationId(job.getDeliveryStations().getFirst().getStationId()); + tasks(openTask); - controller.handleStationCompleted("driver-1", payload); + complete(0); + + verifyNoInteractions(mongoTemplate, emailService); + } + + @Test + void unknownStationIsIgnored() { + complete(7); + + verifyNoInteractions(taskAssignmentService, mongoTemplate, emailService); + } + + @Test + void finalStationIsDeterminedByRouteOrderForJobsWithoutTasks() { + job.setDeliveryStations(List.of(station(7), station(2))); + tasks(); + confirmOnce(7); + allowJobCompletion(); + + complete(7); verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void intermediateStationWithoutTasksStillSendsStationEmail() { + tasks(); + confirmOnce(0); + + complete(0); + + verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void finalConfirmationWaitsForEarlierMandatoryWorkThenSendsOnlyJobEmail() { + ConfirmationTask earlierTask = task(0, false); + tasks(earlierTask, task(1, true)); + confirmOnce(1); + + complete(1); + verifyNoInteractions(emailService); + earlierTask.setCompleted(true); + job.setCompletedStationOrders(List.of(0, 1)); + when(mongoTemplate.findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), eq(Job.class))).thenReturn(job); + allowJobCompletion(); + complete(0); + + verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); + verifyNoMoreInteractions(emailService); + } + + @Test + void confirmationIsPersistedAtomicallyAndExcludesClosedJobs() { + tasks(task(0, true), task(1, false)); + confirmOnce(0); + + complete(0); + + ArgumentCaptor query = ArgumentCaptor.forClass(Query.class); + ArgumentCaptor update = ArgumentCaptor.forClass(Update.class); + ArgumentCaptor options = ArgumentCaptor.forClass(FindAndModifyOptions.class); + verify(mongoTemplate).findAndModify(query.capture(), update.capture(), options.capture(), eq(Job.class)); + assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId) + .containsEntry("completedStationOrders", new Document("$ne", 0)) + .containsEntry("status", new Document("$nin", List.of(JobStatus.COMPLETED, JobStatus.CANCELLED))); + assertThat(update.getValue().getUpdateObject()).containsEntry("$addToSet", + new Document("completedStationOrders", 0)); + assertThat(options.getValue().isReturnNew()).isTrue(); + } + + private void tasks(BaseTask... tasks) { + when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(tasks)); + } + + private void confirmOnce(int stationOrder) { + job.setCompletedStationOrders(List.of(stationOrder)); + when(mongoTemplate.findAndModify(any(Query.class), any(Update.class), + any(FindAndModifyOptions.class), eq(Job.class))).thenReturn(job).thenReturn(null); + } + + private void allowJobCompletion() { + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class))) + .thenReturn(UpdateResult.acknowledged(1, 1L, null)); + } + + private void complete(int stationOrder) { + controller.handleStationCompleted("driver-1", Map.of("jobId", jobId.toHexString(), + "jobNumber", job.getJobNumber(), "stationOrder", stationOrder)); + } + + private DeliveryStation station(int order) { + DeliveryStation station = new DeliveryStation(); + station.setStationId(new ObjectId()); + station.setStationOrder(order); + return station; + } + + private ConfirmationTask task(int stationOrder, boolean completed) { + ConfirmationTask task = new ConfirmationTask("Lieferung bestätigen"); + task.setStationOrder(stationOrder); + task.setCompleted(completed); + return task; } } diff --git a/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java b/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java new file mode 100644 index 0000000..f5c4c78 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/controller/TaskCompletionHistoryTest.java @@ -0,0 +1,101 @@ +package de.assecutor.votianlt.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.model.task.BarcodeTask; +import de.assecutor.votianlt.model.task.CommentTask; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.model.task.PhotoTask; +import de.assecutor.votianlt.model.task.SignatureTask; +import de.assecutor.votianlt.model.task.TodoListTask; +import de.assecutor.votianlt.repository.BarcodeRepository; +import de.assecutor.votianlt.repository.CommentRepository; +import de.assecutor.votianlt.repository.PhotoRepository; +import de.assecutor.votianlt.repository.SignatureRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import de.assecutor.votianlt.service.EmailService; +import de.assecutor.votianlt.service.JobHistoryService; +import de.assecutor.votianlt.service.JobUpdateBroadcaster; +import de.assecutor.votianlt.service.TaskAssignmentService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TaskCompletionHistoryTest { + @Mock + private TaskRepository taskRepository; + @Mock + private PhotoRepository photoRepository; + @Mock + private SignatureRepository signatureRepository; + @Mock + private BarcodeRepository barcodeRepository; + @Mock + private CommentRepository commentRepository; + @Mock + private TaskAssignmentService taskAssignmentService; + @Mock + private JobHistoryService jobHistoryService; + @Mock + private JobUpdateBroadcaster jobUpdateBroadcaster; + @Mock + private EmailService emailService; + @InjectMocks + private MessageController controller; + + static Stream tasks() { + return Stream.of(new ConfirmationTask(), new PhotoTask(), new SignatureTask(), new BarcodeTask(), + new CommentTask(), new TodoListTask()); + } + + @ParameterizedTest + @MethodSource("tasks") + void authenticatedActorAndInstructionsArePreservedForEveryTaskType(BaseTask task) { + String actor = new ObjectId().toHexString(); + task.setId(new ObjectId()); + task.setDescription("Am Seiteneingang abgeben."); + task.setCompletedBy("Unknown"); + Job job = new Job(); + job.setId(new ObjectId()); + when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task)); + when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job)); + Map extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"), + "signatureSvg", "", "signatureNote", "Paket beschädigt", "commentText", "Am Empfang abgegeben"); + Map payload = Map.of("taskId", task.getIdAsString(), "completedBy", "untrusted-payload-user", + "extraData", extra); + + controller.handleTaskCompleted(payload, task.getTaskType(), actor); + + assertThat(task.isCompleted()).isTrue(); + assertThat(task.getCompletedBy()).isEqualTo(actor); + verify(taskRepository).save(task); + verify(jobHistoryService).logTaskCompletion(eq(job.getId()), eq(task.getTaskType()), eq(task.getIdAsString()), + eq(actor), eq(task.getDisplayName()), anyString(), eq(task.getDescription())); + if (task instanceof PhotoTask) { + verify(photoRepository).save(argThat(photo -> actor.equals(photo.getCompletedBy()))); + } else if (task instanceof SignatureTask) { + verify(signatureRepository).save(argThat(signature -> actor.equals(signature.getCompletedBy()) + && "Paket beschädigt".equals(signature.getNote()))); + } else if (task instanceof BarcodeTask) { + verify(barcodeRepository).save(argThat(barcode -> actor.equals(barcode.getCompletedBy()))); + } else if (task instanceof CommentTask) { + verify(commentRepository).save(argThat(comment -> actor.equals(comment.getCompletedBy()))); + } + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/pages/service/OrderNumberTest.java b/backend/src/test/java/de/assecutor/votianlt/pages/service/OrderNumberTest.java new file mode 100644 index 0000000..9edaf27 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/pages/service/OrderNumberTest.java @@ -0,0 +1,50 @@ +package de.assecutor.votianlt.pages.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.repository.JobRepository; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class OrderNumberTest { + @Mock + private JobRepository jobRepository; + @InjectMocks + private AddJobService service; + + @Test + void newOrdersUseTheNewPrefixWithoutRenamingExistingNumbers() { + String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Job legacy = new Job(); + legacy.setJobNumber("JOB" + date + "001"); + when(jobRepository.findAll()).thenReturn(List.of(legacy)); + + String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber"); + + assertThat(number).isEqualTo("AUFTRAG" + date + "001"); + assertThat(legacy.getJobNumber()).isEqualTo("JOB" + date + "001"); + } + + @Test + void existingOrderNumbersAreSkipped() { + String prefix = "AUFTRAG" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + Job existing = new Job(); + existing.setJobNumber(prefix + "002"); + when(jobRepository.findAll()).thenReturn(List.of(existing)); + when(jobRepository.existsByJobNumber(prefix + "002")).thenReturn(true); + + String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber"); + + assertThat(number).isEqualTo(prefix + "003"); + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/pages/view/JobSummaryTaskDescriptionTest.java b/backend/src/test/java/de/assecutor/votianlt/pages/view/JobSummaryTaskDescriptionTest.java new file mode 100644 index 0000000..9e8243c --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/pages/view/JobSummaryTaskDescriptionTest.java @@ -0,0 +1,33 @@ +package de.assecutor.votianlt.pages.view; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.vaadin.flow.component.html.Div; +import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.model.task.SignatureTask; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.test.util.ReflectionTestUtils; + +class JobSummaryTaskDescriptionTest { + static Stream tasks() { + return Stream.of(new ConfirmationTask("OK"), new SignatureTask()); + } + + @ParameterizedTest + @MethodSource("tasks") + void completedTaskStillShowsInstructionsInCardAndStationSummary(BaseTask task) { + JobSummaryView view = new JobSummaryView(null, null, null, null, null, null, null, null, null, null, + null, null, null); + task.setDescription("Am Seiteneingang klingeln.\nBeim Empfang abgeben."); + task.setCompleted(true); + + Div card = ReflectionTestUtils.invokeMethod(view, "createTaskCard", task, task.getDisplayName()); + String summary = ReflectionTestUtils.invokeMethod(view, "summarizeDeliveryTask", task); + + assertThat(card.getElement().getTextRecursively()).contains(task.getDescription()); + assertThat(summary).contains(task.getDescription()); + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java index 32a88ca..2467f5c 100644 --- a/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java +++ b/backend/src/test/java/de/assecutor/votianlt/service/EmailServiceTest.java @@ -2,9 +2,11 @@ package de.assecutor.votianlt.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.task.ConfirmationTask; @@ -71,6 +73,7 @@ class EmailServiceTest { assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:"); assertThat(body).contains("Abgeschlossen von: Max Mustermann"); assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); + assertThat(body).contains("Auftrag: ").doesNotContain("Job:"); } @Test @@ -93,6 +96,86 @@ class EmailServiceTest { assertThat(body).contains("Anzahl erledigter Aufgaben: 2"); assertThat(body).contains("Abgeschlossen von: Max Mustermann"); assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); + assertThat(mailCaptor.getValue().getSubject()).startsWith("Auftrag abgeschlossen - "); + } + + @Test + void creationEmailUsesOrderTerminology() { + User webUser = createWebUser(); + Job job = createJob(webUser, createAssignedAppUser()); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); + + emailService.sendJobCreationNotification(job.getId(), webUser.getId().toHexString()); + + verify(mailSender).send(mailCaptor.capture()); + assertThat(mailCaptor.getValue().getSubject()).startsWith("Neuer Auftrag erstellt - "); + assertThat(mailCaptor.getValue().getText()).contains("ein neuer Auftrag", "Auftrag: ") + .doesNotContain("neuer Job", "Job:"); + } + + @Test + void stationEmailContainsStationAddressAndActualCompletingAppUser() { + User webUser = createWebUser(); + Job job = createJob(webUser, createAssignedAppUser()); + job.setJobNumber("AUFTRAG20260929001"); + DeliveryStation station = createStation(); + job.setDeliveryStations(List.of(station)); + AppUser completingUser = createAssignedAppUser(); + completingUser.setVorname("Erika"); + completingUser.setNachname("Musterfrau"); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); + when(appUserRepository.findById(completingUser.getId())).thenReturn(Optional.of(completingUser)); + + emailService.sendStationCompletionNotification(job.getId(), 1, completingUser.getId().toHexString()); + + verify(mailSender).send(mailCaptor.capture()); + SimpleMailMessage mail = mailCaptor.getValue(); + assertThat(mail.getTo()).containsExactly("anna@example.com"); + assertThat(mail.getFrom()).isEqualTo("noreply@example.com"); + assertThat(mail.getSubject()).isEqualTo("Abgeschlossene Station - AUFTRAG20260929001 - Station 2"); + assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001", + "Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg", + "Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:"); + } + + @Test + void missingStationDoesNotSendStationEmail() { + Job job = createJob(createWebUser(), createAssignedAppUser()); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + + emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1"); + + verifyNoInteractions(mailSender, userRepository); + } + + @Test + void missingRecipientAddressDoesNotSendStationEmail() { + User webUser = createWebUser(); + webUser.setEmail(" "); + Job job = createJob(webUser, createAssignedAppUser()); + job.setDeliveryStations(List.of(createStation())); + when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job)); + when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); + + emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1"); + + verifyNoInteractions(mailSender); + } + + private DeliveryStation createStation() { + DeliveryStation station = new DeliveryStation(); + station.setStationOrder(1); + station.setCompany("Empfänger GmbH"); + station.setFirstName("Petra"); + station.setLastName("Beispiel"); + station.setStreet("Hafenstraße"); + station.setHouseNumber("12"); + station.setAddressAddition("Tor 3"); + station.setZip("20457"); + station.setCity("Hamburg"); + return station; } private User createWebUser() { diff --git a/backend/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java new file mode 100644 index 0000000..ae0a58f --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/JobHistoryServiceTest.java @@ -0,0 +1,144 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.assecutor.votianlt.model.AppUser; +import de.assecutor.votianlt.model.Job; +import de.assecutor.votianlt.model.JobHistory; +import de.assecutor.votianlt.model.JobHistoryType; +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.repository.AppUserRepository; +import de.assecutor.votianlt.repository.JobHistoryRepository; +import de.assecutor.votianlt.repository.JobRepository; +import de.assecutor.votianlt.repository.TaskRepository; +import de.assecutor.votianlt.repository.UserRepository; +import java.util.Optional; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class JobHistoryServiceTest { + @Mock + private JobHistoryRepository historyRepository; + @Mock + private ObjectMapper objectMapper; + @Mock + private AppUserRepository appUserRepository; + @Mock + private UserRepository userRepository; + @Mock + private TaskRepository taskRepository; + @Mock + private JobRepository jobRepository; + @InjectMocks + private JobHistoryService service; + + @Test + void completionKeepsTheActorNameAndTaskDescriptionAsASnapshot() { + AppUser driver = driver(); + ObjectId taskId = new ObjectId(); + when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver)); + + service.logTaskCompletion(new ObjectId(), "CONFIRMATION", taskId.toHexString(), driver.getIdAsString(), + "Bestätigung", "Bestätigung durchgeführt", "Ware am Seiteneingang abgeben.\nBeim Pförtner melden."); + + ArgumentCaptor history = ArgumentCaptor.forClass(JobHistory.class); + verify(historyRepository).save(history.capture()); + JobHistory entry = history.getValue(); + assertThat(entry.getChangedBy()).isEqualTo("Max Mustermann"); + assertThat(entry.getTaskId()).isEqualTo(taskId); + assertThat(service.getTaskDescription(entry)) + .isEqualTo("Ware am Seiteneingang abgeben.\nBeim Pförtner melden."); + verifyNoInteractions(taskRepository); + } + + @Test + void legacyHistoryResolvesDescriptionAndRecordedActorFromTheTask() { + AppUser driver = driver(); + ObjectId taskId = new ObjectId(); + ConfirmationTask task = new ConfirmationTask("OK"); + task.setDescription("Bitte beim Empfang unterschreiben lassen."); + task.setCompletedBy(driver.getIdAsString()); + JobHistory entry = legacyEntry(taskId); + when(taskRepository.findById(taskId)).thenReturn(Optional.of(task)); + when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver)); + + assertThat(service.getTaskDescription(entry)).isEqualTo(task.getDescription()); + assertThat(service.getCreatedByName(entry)).isEqualTo("Max Mustermann"); + verifyNoInteractions(jobRepository); + } + + @Test + void legacyAssigneeFallbackIsClearlyIdentified() { + AppUser driver = driver(); + JobHistory entry = legacyEntry(new ObjectId()); + entry.setChangedBy("Unkown"); + Job job = new Job(); + job.setAppUser(driver.getIdAsString()); + when(jobRepository.findById(entry.getJobId())).thenReturn(Optional.of(job)); + when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver)); + + assertThat(service.getCreatedByName(entry)).isEqualTo("Max Mustermann (zugewiesener App-Nutzer)"); + } + + @Test + void emptyDescriptionSnapshotDoesNotPickUpLaterTaskEdits() { + JobHistory entry = legacyEntry(new ObjectId()); + entry.setTaskDescription(""); + + assertThat(service.getTaskDescription(entry)).isEmpty(); + verifyNoInteractions(taskRepository); + } + + @Test + void webUserIdsAreDisplayedAsNames() { + User user = new User(); + user.setId(new ObjectId()); + user.setFirstname("Anna"); + user.setName("Unternehmer"); + when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); + JobHistory entry = new JobHistory(); + entry.setChangedBy(user.getId().toHexString()); + + assertThat(service.getCreatedByName(entry)).isEqualTo("Anna Unternehmer"); + } + + @Test + void missingLegacyTaskIsHandledWithoutInventingAnAuthor() { + JobHistory entry = new JobHistory(); + entry.setChangeType(JobHistoryType.TASK_COMPLETED); + entry.setDetails("Task-ID: invalid, Task-Typ: PHOTO"); + entry.setChangedBy("Unknown"); + + assertThat(service.getTaskDescription(entry)).isEmpty(); + assertThat(service.getCreatedByName(entry)).isEqualTo("Unbekannt"); + verifyNoInteractions(taskRepository, appUserRepository, jobRepository); + } + + private JobHistory legacyEntry(ObjectId taskId) { + JobHistory entry = new JobHistory(); + entry.setJobId(new ObjectId()); + entry.setChangeType(JobHistoryType.TASK_COMPLETED); + entry.setChangedBy("Unknown"); + entry.setDetails("Task-ID: " + taskId.toHexString() + ", Task-Typ: CONFIRMATION"); + return entry; + } + + private AppUser driver() { + AppUser driver = new AppUser(); + driver.setId(new ObjectId()); + driver.setVorname("Max"); + driver.setNachname("Mustermann"); + return driver; + } +}