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; + } +}