feat: Auftragsdetails und Stationsabschluss für Version 0.9.24

Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen.

Stationsabschlüsse und Benachrichtigungen absichern sowie Historie um Aufgabenbeschreibungen und ausführende Nutzer erweitern.

Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben.
This commit is contained in:
Sven committed 2026-10-01 11:31:59 +02:00
1 parent 7325c77739
commit b642f42954
37 files changed
+1067 -223

No files matched your search

+3 -3
View File
@@ -120,10 +120,10 @@ class _ChatsViewState extends State<ChatsView> {
title: Text(() { title: Text(() {
if (isJobChat) { if (isJobChat) {
if (jobNumber != null && jobNumber.isNotEmpty) { if (jobNumber != null && jobNumber.isNotEmpty) {
return 'Job $jobNumber'; return 'Auftrag $jobNumber';
} }
if (showJobId) { if (showJobId) {
return 'Job $jobId'; return 'Auftrag $jobId';
} }
} }
return localizedChatTitle(context, chat); return localizedChatTitle(context, chat);
@@ -154,7 +154,7 @@ class _ChatsViewState extends State<ChatsView> {
), ),
), ),
child: Text( child: Text(
isJobChat ? 'JOB' : 'ALLG', isJobChat ? 'AUFTRAG' : 'ALLG',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
+2 -23
View File
@@ -145,7 +145,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
if (mounted) { if (mounted) {
final message = final message =
jobNumber != null jobNumber != null
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}' ? 'Auftrag $jobNumber ${AppLocalizations.of(context).jobRemoved}'
: AppLocalizations.of(context).jobRemoved; : AppLocalizations.of(context).jobRemoved;
_showSnack(message, backgroundColor: AppColors.warning); _showSnack(message, backgroundColor: AppColors.warning);
} }
@@ -1406,7 +1406,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Dates and Price // Creation date
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1428,27 +1428,6 @@ class _JobsViewState extends State<JobsView> 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,
),
),
),
],
], ],
), ),
], ],
+18 -15
View File
@@ -52,7 +52,7 @@ class AppLocalizationsDe extends AppLocalizations {
// ==================== NAVIGATION ==================== // ==================== NAVIGATION ====================
@override @override
String get jobs => 'Jobs'; String get jobs => 'Aufträge';
@override @override
String get availableJobs => 'Auftragsliste'; String get availableJobs => 'Auftragsliste';
@@ -155,10 +155,11 @@ class AppLocalizationsDe extends AppLocalizations {
// ==================== JOBS ==================== // ==================== JOBS ====================
@override @override
String get noJobsAssigned => 'Keine Jobs zugewiesen'; String get noJobsAssigned => 'Keine Aufträge zugewiesen';
@override @override
String get noJobsMessage => 'Ihre zugewiesenen Jobs werden hier angezeigt.'; String get noJobsMessage =>
'Ihre zugewiesenen Aufträge werden hier angezeigt.';
@override @override
String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren'; String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren';
@@ -206,13 +207,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get dimensions => 'Abmessungen'; String get dimensions => 'Abmessungen';
@override @override
String get jobDeleted => 'Job gelöscht'; String get jobDeleted => 'Auftrag gelöscht';
@override @override
String get jobDeleteError => 'Fehler beim Löschen des Jobs'; String get jobDeleteError => 'Fehler beim Löschen des Auftrags';
@override @override
String get jobCompleted => 'Job abgeschlossen'; String get jobCompleted => 'Auftrag abgeschlossen';
@override @override
String get from => 'Von'; String get from => 'Von';
@@ -221,10 +222,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get to => 'nach'; String get to => 'nach';
@override @override
String get jobsUpdated => 'Jobs aktualisiert'; String get jobsUpdated => 'Aufträge aktualisiert';
@override @override
String get connectionRestored => 'Verbindung wiederhergestellt. Lade Jobs...'; String get connectionRestored =>
'Verbindung wiederhergestellt. Lade Aufträge...';
@override @override
String get connectionLost => 'Verbindung verloren. Offline.'; String get connectionLost => 'Verbindung verloren. Offline.';
@@ -233,13 +235,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get offline => 'Offline'; String get offline => 'Offline';
@override @override
String get deleteJob => 'Job löschen'; String get deleteJob => 'Auftrag löschen';
@override @override
String get jobRemoved => 'wurde entfernt'; String get jobRemoved => 'wurde entfernt';
@override @override
String get newJobReceived => 'Neuer Job erhalten'; String get newJobReceived => 'Neuer Auftrag erhalten';
@override @override
String get jobDetails => 'Auftragsdetails'; String get jobDetails => 'Auftragsdetails';
@@ -258,7 +260,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get noDeliveryStationsMessage => String get noDeliveryStationsMessage =>
'Dieser Job enthält aktuell keine Lieferstationen.'; 'Dieser Auftrag enthält aktuell keine Lieferstationen.';
@override @override
String get phone => 'Telefon'; String get phone => 'Telefon';
@@ -277,7 +279,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get noTasks => 'Keine Aufgaben'; String get noTasks => 'Keine Aufgaben';
@override @override
String get noTasksMessage => 'Für diesen Job sind keine Aufgaben definiert.'; String get noTasksMessage =>
'Für diesen Auftrag sind keine Aufgaben definiert.';
@override @override
String get taskOrder => 'Reihenfolge'; String get taskOrder => 'Reihenfolge';
@@ -498,13 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get imageSendError => 'Bild konnte nicht gesendet werden.'; String get imageSendError => 'Bild konnte nicht gesendet werden.';
@override @override
String get chatTypeJob => 'Job-spezifisch'; String get chatTypeJob => 'Auftragsbezogen';
@override @override
String get chatTypeGeneral => 'Allgemein'; String get chatTypeGeneral => 'Allgemein';
@override @override
String get jobNumber => 'Job-Nummer'; String get jobNumber => 'Auftragsnummer';
@override @override
String get messages => 'Nachrichten'; String get messages => 'Nachrichten';
@@ -558,7 +561,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get noCargoItemsMessage => String get noCargoItemsMessage =>
'Für diesen Job sind keine Frachtgüter definiert.'; 'Für diesen Auftrag sind keine Frachtgüter definiert.';
@override @override
String get article => 'Artikel'; String get article => 'Artikel';
+7
View File
@@ -46,5 +46,12 @@ String localizedChatTitle(BuildContext context, Chat chat) {
if (chat.type == ChatType.general) { if (chat.type == ChatType.general) {
return AppLocalizations.of(context).generalMessages; 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); return localizeKnownText(context, chat.title);
} }
+1 -1
View File
@@ -98,7 +98,7 @@ class Chat {
final lastMessage = messages.isNotEmpty ? messages.last : null; final lastMessage = messages.isNotEmpty ? messages.last : null;
return Chat( return Chat(
id: id, id: id,
title: 'Job $jobNumber', title: 'Auftrag $jobNumber',
receiver: receiver, receiver: receiver,
type: ChatType.jobSpecific, type: ChatType.jobSpecific,
jobId: jobId, jobId: jobId,
+1 -1
View File
@@ -109,7 +109,7 @@ class Job {
this.assignedTo, this.assignedTo,
this.location, this.location,
this.additionalData, this.additionalData,
}) : title = title ?? 'Job $jobNumber', }) : title = title ?? 'Auftrag $jobNumber',
description = description =
description ?? 'Transport von $pickupCity nach $deliveryCity'; description ?? 'Transport von $pickupCity nach $deliveryCity';
+3 -3
View File
@@ -350,12 +350,12 @@ class ChatService {
String _buildJobTitle(String? jobNumber, String? jobId) { String _buildJobTitle(String? jobNumber, String? jobId) {
if (jobNumber != null && jobNumber.isNotEmpty) { if (jobNumber != null && jobNumber.isNotEmpty) {
return 'Job $jobNumber'; return 'Auftrag $jobNumber';
} }
if (jobId != null && jobId.length >= 6) { 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( String? _determineCounterpart(
+15 -7
View File
@@ -21,9 +21,9 @@ class NotificationService {
'Benachrichtigungen bei neuen Chat-Nachrichten'; 'Benachrichtigungen bei neuen Chat-Nachrichten';
static const String _jobChannelId = 'new_jobs'; static const String _jobChannelId = 'new_jobs';
static const String _jobChannelName = 'Neue Jobs'; static const String _jobChannelName = 'Neue Aufträge';
static const String _jobChannelDescription = static const String _jobChannelDescription =
'Benachrichtigungen bei neuen Job-Zuweisungen'; 'Benachrichtigungen bei neuen Auftragszuweisungen';
int _nextId = 0; int _nextId = 0;
@@ -49,12 +49,15 @@ class NotificationService {
await _plugin await _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>() AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission(); ?.requestNotificationsPermission();
_initialized = true; _initialized = true;
developer.log('NotificationService initialized', developer.log(
name: 'NotificationService'); 'NotificationService initialized',
name: 'NotificationService',
);
} }
Future<void> showChatNotification({ Future<void> showChatNotification({
@@ -87,8 +90,13 @@ class NotificationService {
iOS: iosDetails, iOS: iosDetails,
); );
await _plugin.show(_nextId++, title, body, details, await _plugin.show(
payload: 'chat:$conversationKey'); _nextId++,
title,
body,
details,
payload: 'chat:$conversationKey',
);
} }
Future<void> showJobNotification({ Future<void> showJobNotification({
+8 -8
View File
@@ -440,11 +440,11 @@ class WebSocketService {
if (currentJobCount > 0 && jobs.length > currentJobCount) { if (currentJobCount > 0 && jobs.length > currentJobCount) {
final newCount = jobs.length - currentJobCount; final newCount = jobs.length - currentJobCount;
NotificationService().showJobNotification( NotificationService().showJobNotification(
title: 'Neue Jobs', title: 'Neue Aufträge',
body: body:
newCount == 1 newCount == 1
? 'Sie haben einen neuen Job erhalten.' ? 'Sie haben einen neuen Auftrag erhalten.'
: 'Sie haben $newCount neue Jobs erhalten.', : 'Sie haben $newCount neue Aufträge erhalten.',
); );
} }
@@ -597,11 +597,11 @@ class WebSocketService {
// Show notification with sound for new job // Show notification with sound for new job
NotificationService().showJobNotification( NotificationService().showJobNotification(
title: 'Neuer Job', title: 'Neuer Auftrag',
body: body:
jobNumber != null && jobNumber.isNotEmpty jobNumber != null && jobNumber.isNotEmpty
? 'Job $jobNumber wurde Ihnen zugewiesen.' ? 'Auftrag $jobNumber wurde Ihnen zugewiesen.'
: 'Sie haben einen neuen Job erhalten.', : 'Sie haben einen neuen Auftrag erhalten.',
); );
// Publish event via DartMQ for UI to handle // Publish event via DartMQ for UI to handle
@@ -642,8 +642,8 @@ class WebSocketService {
final jobNumber = message.jobNumber ?? ''; final jobNumber = message.jobNumber ?? '';
notificationTitle = notificationTitle =
jobNumber.isNotEmpty jobNumber.isNotEmpty
? 'Nachricht zu Job $jobNumber' ? 'Nachricht zu Auftrag $jobNumber'
: 'Neue Job-Nachricht'; : 'Neue Auftragsnachricht';
} else { } else {
notificationTitle = 'Neue Nachricht'; notificationTitle = 'Neue Nachricht';
} }
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.9.23+923 version: 0.9.24+924
environment: environment:
sdk: ^3.7.0 sdk: ^3.7.0
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.23</revision> <revision>0.9.24</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
@@ -163,13 +163,13 @@ public class AiStatisticsService {
- Untersuche, erwähne oder vermute niemals Daten anderer Benutzer - Untersuche, erwähne oder vermute niemals Daten anderer Benutzer
WICHTIG - ANTWORTSTIL: 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 ausschließlich ein JSON-Objekt im geforderten Format aus
- Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus - Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus
- Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen - 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" - 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 - 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 - Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage
- Maximal 1-2 Sätze - Maximal 1-2 Sätze
- Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen - Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen
@@ -803,27 +803,27 @@ public class AiStatisticsService {
List<Job> jobs; List<Job> jobs;
if (customerFilter != null && statusFilter != null) { if (customerFilter != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter); 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())); customerFilter, statusFilter.getDisplayName()));
} else if (customerFilter != null) { } else if (customerFilter != null) {
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter); 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) { } else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); 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())); statusFilter.getDisplayName()));
} else { } else {
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); 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) // Show job summaries (max 10)
int shown = 0; int shown = 0;
for (Job job : jobs) { for (Job job : jobs) {
if (shown >= 10) { 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; break;
} }
context.append( context.append(
@@ -943,7 +943,7 @@ public class AiStatisticsService {
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt", job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt")) job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt"))
.toList(); .toList();
return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows); return buildHtmlTable(List.of("Auftragsnummer", "Kunde", "Status"), rows);
} }
private String buildStatusTableHtml(String currentUserId, String customerFilter) { private String buildStatusTableHtml(String currentUserId, String customerFilter) {
@@ -1209,17 +1209,17 @@ public class AiStatisticsService {
List<Job> jobs; List<Job> jobs;
if (customer != null && statusFilter != null) { if (customer != null && statusFilter != null) {
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter); 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()); statusFilter.getDisplayName());
} else if (customer != null) { } else if (customer != null) {
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer); 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) { } else if (statusFilter != null) {
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter); 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 { } else {
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20); jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
yield String.format("%d aktuelle Jobs.", jobs.size()); yield String.format("%d aktuelle Aufträge.", jobs.size());
} }
} }
case "status" -> { case "status" -> {
@@ -6,6 +6,7 @@ import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.CargoItem; import de.assecutor.votianlt.model.CargoItem;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.task.BaseTask; import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.SignatureTask; import de.assecutor.votianlt.model.task.SignatureTask;
@@ -32,6 +33,7 @@ import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.messaging.MessagingPublisher; import de.assecutor.votianlt.messaging.MessagingPublisher;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.data.mongodb.core.MongoTemplate; 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.Criteria;
import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update; 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.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
@@ -184,30 +187,34 @@ public class MessageController {
* go to generic processing. * go to generic processing.
*/ */
public void handleTaskCompleted(Map<String, Object> payload, String taskType) { public void handleTaskCompleted(Map<String, Object> payload, String taskType) {
handleTaskCompleted(payload, taskType, null);
}
public void handleTaskCompleted(Map<String, Object> payload, String taskType, String appUserId) {
String key = taskType == null ? "" : taskType.trim().toUpperCase(); String key = taskType == null ? "" : taskType.trim().toUpperCase();
switch (key) { switch (key) {
case "PHOTO" -> processPhotoTaskCompletion(payload); case "PHOTO" -> processPhotoTaskCompletion(payload, appUserId);
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload); case "CONFIRMATION" -> processConfirmationTaskCompletion(payload, appUserId);
case "SIGNATURE" -> processSignatureTaskCompletion(payload); case "SIGNATURE" -> processSignatureTaskCompletion(payload, appUserId);
case "TODOLIST" -> processTodoListTaskCompletion(payload); case "TODOLIST" -> processTodoListTaskCompletion(payload, appUserId);
case "BARCODE" -> processBarcodeTaskCompletion(payload); case "BARCODE" -> processBarcodeTaskCompletion(payload, appUserId);
case "COMMENT" -> processCommentTaskCompletion(payload); case "COMMENT" -> processCommentTaskCompletion(payload, appUserId);
default -> log.error("[TASK] Unknown taskType: {}", taskType); default -> log.error("[TASK] Unknown taskType: {}", taskType);
} }
} }
private void processConfirmationTaskCompletion(Map<String, Object> payload) { private void processConfirmationTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
completeTaskWithHistory(taskId, "Bestätigung durchgeführt"); completeTaskWithHistory(taskId, "Bestätigung durchgeführt", appUserId);
} }
private void processTodoListTaskCompletion(Map<String, Object> payload) { private void processTodoListTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt"); completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt", appUserId);
} }
private void processBarcodeTaskCompletion(Map<String, Object> payload) { private void processBarcodeTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
try { try {
var opt = taskRepository.findById(new ObjectId(taskId.toString())); var opt = taskRepository.findById(new ObjectId(taskId.toString()));
@@ -226,7 +233,7 @@ public class MessageController {
if (!barcodes.isEmpty()) { if (!barcodes.isEmpty()) {
for (String barcodeString : barcodes) { 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, Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
completedBy); completedBy);
barcodeRepository.save(barcodeEntry); barcodeRepository.save(barcodeEntry);
@@ -244,13 +251,13 @@ public class MessageController {
extraDataSummary = "Keine Extra-Daten"; extraDataSummary = "Keine Extra-Daten";
} }
completeTaskWithHistory(taskId, extraDataSummary); completeTaskWithHistory(taskId, extraDataSummary, appUserId);
} catch (Exception ex) { } catch (Exception ex) {
log.error("[TASK] Barcode completion error: {}", ex.getMessage()); log.error("[TASK] Barcode completion error: {}", ex.getMessage());
} }
} }
private void processSignatureTaskCompletion(Map<String, Object> payload) { private void processSignatureTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
try { try {
var opt = taskRepository.findById(new ObjectId(taskId.toString())); var opt = taskRepository.findById(new ObjectId(taskId.toString()));
@@ -267,7 +274,7 @@ public class MessageController {
String signatureNote = signatureNoteObj instanceof String s ? s : null; String signatureNote = signatureNoteObj instanceof String s ? s : null;
if (signatureSvgObj instanceof String signatureSvg) { if (signatureSvgObj instanceof String signatureSvg) {
if (!signatureSvg.isBlank()) { 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, Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
signatureNote, completedBy); signatureNote, completedBy);
signatureRepository.save(signatureEntry); signatureRepository.save(signatureEntry);
@@ -285,13 +292,13 @@ public class MessageController {
extraDataSummary = "Keine Extra-Daten"; extraDataSummary = "Keine Extra-Daten";
} }
completeTaskWithHistory(taskId, extraDataSummary); completeTaskWithHistory(taskId, extraDataSummary, appUserId);
} catch (Exception ex) { } catch (Exception ex) {
log.error("[TASK] Signature completion error: {}", ex.getMessage()); log.error("[TASK] Signature completion error: {}", ex.getMessage());
} }
} }
private void processPhotoTaskCompletion(Map<String, Object> payload) { private void processPhotoTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
try { try {
var opt = taskRepository.findById(new ObjectId(taskId.toString())); var opt = taskRepository.findById(new ObjectId(taskId.toString()));
@@ -310,7 +317,7 @@ public class MessageController {
if (!photos.isEmpty()) { if (!photos.isEmpty()) {
for (String photoString : photos) { 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); Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
photoRepository.save(photoEntry); photoRepository.save(photoEntry);
} }
@@ -325,13 +332,13 @@ public class MessageController {
extraDataSummary = "Keine Extra-Daten"; extraDataSummary = "Keine Extra-Daten";
} }
completeTaskWithHistory(taskId, extraDataSummary); completeTaskWithHistory(taskId, extraDataSummary, appUserId);
} catch (Exception ex) { } catch (Exception ex) {
log.error("[TASK] Photo completion error: {}", ex.getMessage()); log.error("[TASK] Photo completion error: {}", ex.getMessage());
} }
} }
private void processCommentTaskCompletion(Map<String, Object> payload) { private void processCommentTaskCompletion(Map<String, Object> payload, String appUserId) {
Object taskId = payload.get("taskId"); Object taskId = payload.get("taskId");
try { try {
var opt = taskRepository.findById(new ObjectId(taskId.toString())); var opt = taskRepository.findById(new ObjectId(taskId.toString()));
@@ -345,7 +352,7 @@ public class MessageController {
if (extra instanceof Map<?, ?> extraData) { if (extra instanceof Map<?, ?> extraData) {
Object commentTextObj = extraData.get("commentText"); Object commentTextObj = extraData.get("commentText");
if (commentTextObj instanceof String commentText && !commentText.isBlank()) { 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); Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
commentRepository.save(commentEntry); commentRepository.save(commentEntry);
extraDataSummary = "Kommentar: " + commentText; extraDataSummary = "Kommentar: " + commentText;
@@ -356,13 +363,13 @@ public class MessageController {
extraDataSummary = "Kommentar abgegeben"; extraDataSummary = "Kommentar abgegeben";
} }
completeTaskWithHistory(taskId, extraDataSummary); completeTaskWithHistory(taskId, extraDataSummary, appUserId);
} catch (Exception ex) { } catch (Exception ex) {
log.error("[TASK] Comment completion error: {}", ex.getMessage()); 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(); String taskIdStr = tid.toString();
try { try {
ObjectId taskId = new ObjectId(taskIdStr); ObjectId taskId = new ObjectId(taskIdStr);
@@ -371,6 +378,7 @@ public class MessageController {
return; return;
} }
BaseTask task = opt.get(); BaseTask task = opt.get();
task.setCompletedBy(resolveTaskActor(task, appUserId));
task.setCompleted(true); task.setCompleted(true);
task.setCompletedAt(LocalDateTime.now()); task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task); taskRepository.save(task);
@@ -385,9 +393,9 @@ public class MessageController {
try { try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType; 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, jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
extraDataSummary); extraDataSummary, task.getDescription() != null ? task.getDescription() : "");
} catch (Exception e) { } catch (Exception e) {
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId, log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
e.getMessage(), e); e.getMessage(), e);
@@ -396,7 +404,7 @@ public class MessageController {
// Send email notification for task completion // Send email notification for task completion
try { try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; 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); emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
// Job completion is no longer auto-triggered by task completion. // Job completion is no longer auto-triggered by task completion.
// It is now driven by explicit station_completed messages from the app // 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) { private String resolveTaskActor(BaseTask task, String appUserId) {
try { if (appUserId != null && !appUserId.isBlank()) {
var allTasks = taskAssignmentService.findTasksForJob(jobId); return appUserId;
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<String> 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);
} }
return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
} }
private boolean updateJobStatusToCompleted(ObjectId jobId) { private boolean updateJobStatusToCompleted(ObjectId jobId) {
try { 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()); Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0; boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
if (completed) { if (completed) {
@@ -472,8 +454,9 @@ public class MessageController {
* "hasIncompleteOptionalTasks": false * "hasIncompleteOptionalTasks": false
* } * }
* *
* The job is marked as completed once this message is received and all * Intermediate stations send a station notification. The final route station
* mandatory tasks across all stations are completed. * 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<String, Object> payload) { public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
try { try {
@@ -493,19 +476,76 @@ public class MessageController {
return; return;
} }
ObjectId jobId = jobOpt.get().getId(); Job job = jobOpt.get();
ObjectId jobId = job.getId();
int stationOrder = Integer.parseInt(String.valueOf(payload.get("stationOrder")));
List<DeliveryStation> 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"; String completedBy = appUserId != null ? appUserId : "Unknown";
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber, 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); jobUpdateBroadcaster.broadcast(jobId);
} catch (Exception e) { } 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 * Handle incoming message from a client via WebSocket. Client sends to
* /server/message with payload: { "content": "message payload", "contentType": * /server/message with payload: { "content": "message payload", "contentType":
@@ -66,12 +66,12 @@ public class MessagingConfig {
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> { webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> { handlePayload(payload, payloadMap -> {
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null; 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 // Station completion handler — notifies for intermediate stations and completes
// tasks have been finished and the app confirms the station is done. // the job after the final station and all mandatory tasks are done.
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> { webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
handlePayload(payload, payloadMap -> { handlePayload(payload, payloadMap -> {
messageController.handleStationCompleted(appUserId, payloadMap); messageController.handleStationCompleted(appUserId, payloadMap);
@@ -168,6 +168,9 @@ public class Job {
@Field("delivery_stations") @Field("delivery_stations")
private List<DeliveryStation> deliveryStations = new ArrayList<>(); private List<DeliveryStation> deliveryStations = new ArrayList<>();
@Field("completed_station_orders")
private List<Integer> completedStationOrders = new ArrayList<>();
/** /**
* Returns the ObjectId as string for JSON serialization. This ensures that the * 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. * job id is returned as a string when jobs are retrieved via API.
@@ -49,6 +49,10 @@ public class JobHistory {
*/ */
private String details; 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.) * Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
*/ */
@@ -96,4 +100,4 @@ public class JobHistory {
public String getJobIdAsString() { public String getJobIdAsString() {
return jobId != null ? jobId.toHexString() : null; return jobId != null ? jobId.toHexString() : null;
} }
} }
@@ -172,7 +172,7 @@ public class AddJobService {
* Generiert eine eindeutige Auftragsnummer * Generiert eine eindeutige Auftragsnummer
*/ */
private String generateJobNumber() { private String generateJobNumber() {
String prefix = "JOB"; String prefix = "AUFTRAG";
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
// Zähle Aufträge des aktuellen Tages // Zähle Aufträge des aktuellen Tages
@@ -209,6 +209,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
cardContent.add(description); 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 // Photo preview for photo tasks
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null
&& entry.getDetails().contains("Task-Typ: PHOTO")) { && entry.getDetails().contains("Task-Typ: PHOTO")) {
@@ -239,9 +247,10 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
} }
} }
// Changed by (if available) // Author of this history entry.
if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) { String createdBy = jobHistoryService.getCreatedByName(entry);
Span changedBy = new Span(getTranslation("jobhistory.changedby", entry.getChangedBy())); if (createdBy != null && !createdBy.isBlank()) {
Span changedBy = new Span(getTranslation("jobhistory.changedby", createdBy));
changedBy.getStyle().set("color", "var(--lumo-secondary-text-color)") 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("font-size", "var(--lumo-font-size-xs)").set("margin-top", "var(--lumo-space-xs)")
.set("display", "block"); .set("display", "block");
@@ -260,7 +269,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
if (entry.getChangeType() == JobHistoryType.CREATE) { if (entry.getChangeType() == JobHistoryType.CREATE) {
return getTranslation("jobhistory.entry.create.reason"); return getTranslation("jobhistory.entry.create.reason");
} }
return entry.getReason() != null ? entry.getReason() : ""; return updateOrderTerminology(entry.getReason());
} }
private String getLocalizedDescription(JobHistory entry) { private String getLocalizedDescription(JobHistory entry) {
@@ -270,7 +279,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
if (entry.getChangeType() == JobHistoryType.CREATE) { if (entry.getChangeType() == JobHistoryType.CREATE) {
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription())); 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) { private String extractDescriptionValue(String description) {
@@ -555,6 +555,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
} }
private String summarizeDeliveryTask(BaseTask task) { 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) { if (task instanceof ConfirmationTask confirmationTask) {
String buttonText = trimToNull(confirmationTask.getButtonText()); String buttonText = trimToNull(confirmationTask.getButtonText());
if (buttonText != null) { if (buttonText != null) {
@@ -1140,6 +1146,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
} }
dialogContent.add(statusSpan); 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 // Task-specific details
addTaskSpecificDetails(dialogContent, task); addTaskSpecificDetails(dialogContent, task);
@@ -1150,7 +1162,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt()))); dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt())));
} }
if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) { 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<String>, Has
taskDescription.addClassName("job-task-description"); taskDescription.addClassName("job-task-description");
taskContent.add(headerRow, taskDescription); 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 // Status indicator
Div statusIndicator = new Div(); Div statusIndicator = new Div();
@@ -1,6 +1,7 @@
package de.assecutor.votianlt.service; package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.AppUserRepository;
@@ -20,6 +21,8 @@ import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -77,7 +80,7 @@ public class EmailService {
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
message.setSubject( 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 fullName = buildFullName(user);
String taskTypeName = getTaskTypeDisplayName(taskType); String taskTypeName = getTaskTypeDisplayName(taskType);
@@ -85,7 +88,7 @@ public class EmailService {
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n"); body.append("Hallo ").append(fullName).append(",\n\n");
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n"); body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n"); body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
if (job.getDeliveryCompany() != null) { if (job.getDeliveryCompany() != null) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
} }
@@ -195,6 +198,69 @@ public class EmailService {
}; };
} }
@Async
public void sendStationCompletionNotification(ObjectId jobId, int stationOrder, String completedBy) {
try {
Optional<Job> 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<User> 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 @Async
public void sendJobCompletionNotification(ObjectId jobId, String completedBy) { public void sendJobCompletionNotification(ObjectId jobId, String completedBy) {
try { try {
@@ -236,7 +302,7 @@ public class EmailService {
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
message.setSubject( 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); String fullName = buildFullName(user);
@@ -246,8 +312,8 @@ public class EmailService {
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n"); body.append("Hallo ").append(fullName).append(",\n\n");
body.append("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen:\n\n"); body.append("alle Aufgaben für den folgenden Auftrag wurden erfolgreich 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) { if (job.getDeliveryCompany() != null) {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); 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("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n"); body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n"); body.append("Der Auftrag ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
body.append("Mit freundlichen Grüßen,\n"); body.append("Mit freundlichen Grüßen,\n");
body.append("Ihr Votianlt-System"); body.append("Ihr Votianlt-System");
@@ -325,7 +391,7 @@ public class EmailService {
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
message.setSubject( 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); String fullName = buildFullName(user);
@@ -335,8 +401,8 @@ public class EmailService {
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
body.append("Hallo ").append(fullName).append(",\n\n"); body.append("Hallo ").append(fullName).append(",\n\n");
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n"); body.append("ein neuer Auftrag wurde erfolgreich erstellt:\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.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) { if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n"); body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
@@ -374,7 +440,7 @@ public class EmailService {
: "Unbekannt") : "Unbekannt")
.append("\n\n"); .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("Mit freundlichen Grüßen,\n");
body.append("Ihr Votianlt-System"); body.append("Ihr Votianlt-System");
@@ -5,12 +5,20 @@ import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobHistory; import de.assecutor.votianlt.model.JobHistory;
import de.assecutor.votianlt.model.JobHistoryType; import de.assecutor.votianlt.model.JobHistoryType;
import de.assecutor.votianlt.model.JobStatus; 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.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 lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service @Service
@Slf4j @Slf4j
@@ -18,10 +26,20 @@ public class JobHistoryService {
private final JobHistoryRepository jobHistoryRepository; private final JobHistoryRepository jobHistoryRepository;
private final ObjectMapper objectMapper; 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.jobHistoryRepository = jobHistoryRepository;
this.objectMapper = objectMapper; 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) { public void logJobCreation(Job job, String createdBy) {
try { try {
JobHistory history = new JobHistory(job.getId(), "Job erstellt", JobHistory history = new JobHistory(job.getId(), "Auftrag erstellt",
"Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"), "Neuer Auftrag wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
createdBy, JobHistoryType.CREATE, null, "Job erstellt"); createdBy, JobHistoryType.CREATE, null, "Auftrag erstellt");
if (job.getDeliveryCompany() != null) { if (job.getDeliveryCompany() != null) {
history.setDetails("Kunde: " + job.getDeliveryCompany()); history.setDetails("Kunde: " + job.getDeliveryCompany());
@@ -70,7 +88,7 @@ public class JobHistoryService {
try { try {
String description = generateUpdateDescription(oldJob, newJob); 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), description, changedBy, JobHistoryType.UPDATE, serializeJobForComparison(oldJob),
serializeJobForComparison(newJob)); serializeJobForComparison(newJob));
@@ -93,6 +111,11 @@ public class JobHistoryService {
*/ */
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy, public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
String taskDisplayName, String extraDataSummary) { 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 { try {
String taskName = taskDisplayName != null ? taskDisplayName : taskType; String taskName = taskDisplayName != null ? taskDisplayName : taskType;
String description = String.format("Aufgabe abgeschlossen: %s", taskName); String description = String.format("Aufgabe abgeschlossen: %s", taskName);
@@ -101,8 +124,10 @@ public class JobHistoryService {
description += " - " + extraDataSummary; 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"); JobHistoryType.TASK_COMPLETED, "In Bearbeitung", "Abgeschlossen");
history.setTaskId(ObjectId.isValid(taskId) ? new ObjectId(taskId) : null);
history.setTaskDescription(taskDescription);
// Detaillierte Informationen in details speichern // Detaillierte Informationen in details speichern
StringBuilder details = new StringBuilder(); StringBuilder details = new StringBuilder();
@@ -132,11 +157,11 @@ public class JobHistoryService {
try { try {
String description; String description;
if (oldAssignee == null && newAssignee != null) { if (oldAssignee == null && newAssignee != null) {
description = "Job zugewiesen an: " + newAssignee; description = "Auftrag zugewiesen an: " + newAssignee;
} else if (oldAssignee != null && newAssignee == null) { } else if (oldAssignee != null && newAssignee == null) {
description = "Job-Zuweisung entfernt von: " + oldAssignee; description = "Auftragszuweisung entfernt von: " + oldAssignee;
} else { } 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, JobHistory history = new JobHistory(job.getId(), "Zuweisung geändert", description, changedBy,
@@ -177,6 +202,85 @@ public class JobHistoryService {
return jobHistoryRepository.countByJobId(jobId); 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<String> 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<String> 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<BaseTask> 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 // Helper methods
private String formatStatus(JobStatus status) { private String formatStatus(JobStatus status) {
@@ -197,7 +301,7 @@ public class JobHistoryService {
} }
private String generateUpdateDescription(Job oldJob, Job newJob) { private String generateUpdateDescription(Job oldJob, Job newJob) {
StringBuilder description = new StringBuilder("Job-Daten aktualisiert"); StringBuilder description = new StringBuilder("Auftragsdaten aktualisiert");
if (oldJob == null) { if (oldJob == null) {
return description.toString(); return description.toString();
@@ -241,4 +345,4 @@ public class JobHistoryService {
return "Serialization failed"; return "Serialization failed";
} }
} }
} }
@@ -260,7 +260,7 @@ page.title.statistics=Statistiken
page.title.password.forget=Passwort zurücksetzen page.title.password.forget=Passwort zurücksetzen
page.title.invoices=Rechnungen page.title.invoices=Rechnungen
page.title.appusers=App-Nutzer page.title.appusers=App-Nutzer
page.title.job.history=Job Historie page.title.job.history=Auftrags-Historie
page.title.message.history=Nachrichtenverlauf page.title.message.history=Nachrichtenverlauf
page.title.myinvoices=Rechnungen page.title.myinvoices=Rechnungen
page.title.job.create=Neuen Auftrag anlegen page.title.job.create=Neuen Auftrag anlegen
@@ -583,11 +583,11 @@ addjob.validation.address.correct=Adresse korrigieren
# Job Summary # Job Summary
jobsummary.title=Zusammenfassung jobsummary.title=Zusammenfassung
jobsummary.error.noid=Keine Job-ID angegeben jobsummary.error.noid=Keine Auftrags-ID angegeben
jobsummary.error.invalidid=Ungültige Job-ID Format: {0} jobsummary.error.invalidid=Ungültiges Format der Auftrags-ID: {0}
jobsummary.error.notfound=Job mit ID {0} nicht gefunden jobsummary.error.notfound=Auftrag mit ID {0} nicht gefunden
jobsummary.button.sendmessage=Nachricht senden jobsummary.button.sendmessage=Nachricht senden
jobsummary.button.jobhistory=Job Historie jobsummary.button.jobhistory=Auftrags-Historie
jobsummary.button.complete=Auftrag manuell abschließen jobsummary.button.complete=Auftrag manuell abschließen
jobsummary.dialog.complete.title=Auftrag abschließen jobsummary.dialog.complete.title=Auftrag abschließen
jobsummary.dialog.complete.text=Möchten Sie den Auftrag {0} manuell 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 # Create Invoice
createinvoice.title=Rechnung erstellen \u2013 Auftrag {0} createinvoice.title=Rechnung erstellen \u2013 Auftrag {0}
createinvoice.error.invalidid=Ungültige Job-ID createinvoice.error.invalidid=Ungültige Auftrags-ID
createinvoice.error.notfound=Job nicht gefunden createinvoice.error.notfound=Auftrag nicht gefunden
createinvoice.button.create=Rechnung erstellen createinvoice.button.create=Rechnung erstellen
createinvoice.section.job=Auftragsdetails createinvoice.section.job=Auftragsdetails
createinvoice.section.route=Streckeninfo createinvoice.section.route=Streckeninfo
@@ -986,7 +986,7 @@ csv.header.customer=Auftraggeber
csv.header.jobnumber=Auftragsnummer csv.header.jobnumber=Auftragsnummer
csv.header.jobdate=Auftragsdatum csv.header.jobdate=Auftragsdatum
csv.header.destination=Zielort csv.header.destination=Zielort
csv.filename=jobs.csv csv.filename=auftraege.csv
# DatePicker I18n # DatePicker I18n
datepicker.month.januar=Januar datepicker.month.januar=Januar
@@ -1022,15 +1022,15 @@ jobhistory.status.pickedup=Abgeholt
jobhistory.status.intransit=Unterwegs jobhistory.status.intransit=Unterwegs
jobhistory.status.delivered=Zugestellt jobhistory.status.delivered=Zugestellt
jobhistory.image.alt=Vergrößertes Foto jobhistory.image.alt=Vergrößertes Foto
jobhistory.title=Jobhistorie jobhistory.title=Auftrags-Historie
jobhistory.header=Jobhistorie für {0} jobhistory.header=Auftrags-Historie für {0}
jobhistory.info.customer=Kunde: {0} jobhistory.info.customer=Kunde: {0}
jobhistory.info.createdat=Erstellt am: {0} jobhistory.info.createdat=Erstellt am: {0}
jobhistory.info.status=Status: {0} jobhistory.info.status=Status: {0}
jobhistory.count={0} Einträge in der Historie jobhistory.count={0} Einträge in der Historie
jobhistory.changedby=Geändert von: {0} jobhistory.changedby=Erstellt von: {0}
jobhistory.entry.create.reason=Job erstellt jobhistory.entry.create.reason=Auftrag erstellt
jobhistory.entry.create.description=Neuer Job wurde erstellt: {0} jobhistory.entry.create.description=Neuer Auftrag wurde erstellt: {0}
# Version # Version
version.label=Version version.label=Version
@@ -870,7 +870,7 @@ jobhistory.info.customer=Klient: {0}
jobhistory.info.createdat=Loodud: {0} jobhistory.info.createdat=Loodud: {0}
jobhistory.info.status=Olek: {0} jobhistory.info.status=Olek: {0}
jobhistory.count={0} kirjet ajaloos jobhistory.count={0} kirjet ajaloos
jobhistory.changedby=Muutnud: {0} jobhistory.changedby=Loonud: {0}
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0} jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
version.label=Versioon version.label=Versioon
@@ -1028,7 +1028,7 @@ jobhistory.info.customer=Customer: {0}
jobhistory.info.createdat=Created on: {0} jobhistory.info.createdat=Created on: {0}
jobhistory.info.status=Status: {0} jobhistory.info.status=Status: {0}
jobhistory.count={0} entries in history 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.reason=Job Created
jobhistory.entry.create.description=New job was created: {0} jobhistory.entry.create.description=New job was created: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=Cliente: {0}
jobhistory.info.createdat=Creado el: {0} jobhistory.info.createdat=Creado el: {0}
jobhistory.info.status=Estado: {0} jobhistory.info.status=Estado: {0}
jobhistory.count={0} entradas en el historial 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.reason=Pedido creado
jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0} jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=Client : {0}
jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0} jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0}
jobhistory.info.status=Statut : {0} jobhistory.info.status=Statut : {0}
jobhistory.count={0} entr\u00e9es dans l'historique 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.reason=Mission cr\u00e9\u00e9e
jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0} jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0}
@@ -961,7 +961,7 @@ jobhistory.info.customer=Klientas: {0}
jobhistory.info.createdat=Sukurta: {0} jobhistory.info.createdat=Sukurta: {0}
jobhistory.info.status=Būsena: {0} jobhistory.info.status=Būsena: {0}
jobhistory.count={0} įrašų istorijoje jobhistory.count={0} įrašų istorijoje
jobhistory.changedby=Pakeitė: {0} jobhistory.changedby=Sukūrė: {0}
jobhistory.entry.create.reason=Užsakymas sukurtas jobhistory.entry.create.reason=Užsakymas sukurtas
jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0} jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klients: {0}
jobhistory.info.createdat=Izveidots: {0} jobhistory.info.createdat=Izveidots: {0}
jobhistory.info.status=Statuss: {0} jobhistory.info.status=Statuss: {0}
jobhistory.count={0} ieraksti vēsturē jobhistory.count={0} ieraksti vēsturē
jobhistory.changedby=Mainīja: {0} jobhistory.changedby=Izveidoja: {0}
jobhistory.entry.create.reason=Uzdevums izveidots jobhistory.entry.create.reason=Uzdevums izveidots
jobhistory.entry.create.description=Izveidots jauns uzdevums: {0} jobhistory.entry.create.description=Izveidots jauns uzdevums: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klient: {0}
jobhistory.info.createdat=Utworzono dnia: {0} jobhistory.info.createdat=Utworzono dnia: {0}
jobhistory.info.status=Status: {0} jobhistory.info.status=Status: {0}
jobhistory.count={0} wpis\u00f3w w historii 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.reason=Zlecenie utworzone
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0} jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=Клиент: {0}
jobhistory.info.createdat=Создано: {0} jobhistory.info.createdat=Создано: {0}
jobhistory.info.status=Статус: {0} jobhistory.info.status=Статус: {0}
jobhistory.count={0} записей в истории jobhistory.count={0} записей в истории
jobhistory.changedby=Изменено: {0} jobhistory.changedby=Создано: {0}
jobhistory.entry.create.reason=Заказ создан jobhistory.entry.create.reason=Заказ создан
jobhistory.entry.create.description=Создан новый заказ: {0} jobhistory.entry.create.description=Создан новый заказ: {0}
@@ -959,7 +959,7 @@ jobhistory.info.customer=M\u00fc\u015fteri: {0}
jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0} jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0}
jobhistory.info.status=Durum: {0} jobhistory.info.status=Durum: {0}
jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t 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.reason=\u0130\u015f olu\u015fturuldu
jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0} jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0}
@@ -3,13 +3,17 @@ package de.assecutor.votianlt.controller;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import com.mongodb.client.result.UpdateResult; import com.mongodb.client.result.UpdateResult;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.ConfirmationTask; import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.service.EmailService; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.core.query.Update;
@@ -48,65 +55,242 @@ class StationCompletionTest {
private MessageController controller; private MessageController controller;
private final ObjectId jobId = new ObjectId(); private final ObjectId jobId = new ObjectId();
private ConfirmationTask mandatoryTask; private Job job;
private Map<String, Object> payload;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
Job job = new Job(); job = new Job();
job.setId(jobId); 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)); 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 @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))) when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null)); .thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null));
controller.handleStationCompleted("driver-1", payload); complete(1);
controller.handleStationCompleted("driver-1", payload); complete(1);
verify(emailService).sendJobCompletionNotification(jobId, "driver-1"); verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
verifyNoMoreInteractions(emailService);
} }
@Test @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))) 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); verifyNoInteractions(emailService);
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class); verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.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));
} }
@Test @Test
void incompleteMandatoryTaskPreventsJobCompletion() { void incompleteMandatoryTaskPreventsStationCompletion() {
mandatoryTask.setCompleted(false); tasks(task(0, false), task(1, true));
controller.handleStationCompleted("driver-1", payload); complete(0);
verifyNoInteractions(mongoTemplate, emailService); verifyNoInteractions(mongoTemplate, emailService);
} }
@Test @Test
void unfinishedOptionalTaskDoesNotPreventExplicitCompletion() { void stationIdTakesPrecedenceOverStaleTaskOrder() {
ConfirmationTask optionalTask = new ConfirmationTask("Zusätzliche Bestätigung"); ConfirmationTask openTask = task(1, false);
optionalTask.setOptional(true); openTask.setStationId(job.getDeliveryStations().getFirst().getStationId());
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask, optionalTask)); tasks(openTask);
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
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"); 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> query = ArgumentCaptor.forClass(Query.class);
ArgumentCaptor<Update> update = ArgumentCaptor.forClass(Update.class);
ArgumentCaptor<FindAndModifyOptions> 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;
} }
} }
@@ -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<BaseTask> 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<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
"signatureSvg", "<svg/>", "signatureNote", "Paket beschädigt", "commentText", "Am Empfang abgegeben");
Map<String, Object> 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())));
}
}
}
@@ -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");
}
}
@@ -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<BaseTask> 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());
}
}
@@ -2,9 +2,11 @@ package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.task.ConfirmationTask; 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("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
assertThat(body).contains("Abgeschlossen von: Max Mustermann"); assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
assertThat(body).contains("Auftrag: ").doesNotContain("Job:");
} }
@Test @Test
@@ -93,6 +96,86 @@ class EmailServiceTest {
assertThat(body).contains("Anzahl erledigter Aufgaben: 2"); assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
assertThat(body).contains("Abgeschlossen von: Max Mustermann"); assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); 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() { private User createWebUser() {
@@ -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<JobHistory> 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;
}
}