feat: Frachtanzeige und Stationsabschluss für Version 0.9.23

Fracht mit Maßen und Gewicht anzeigen und Stationsabschlüsse gegen Wiederholung absichern. Backend-Abschluss atomar ausführen und mit Regressionstests abdecken. Optionale Aufgaben beim Kopieren erhalten und Kunden mit Adressen anzeigen. App und Backend auf Version 0.9.23 aktualisieren.
This commit is contained in:
Sven committed 2026-09-28 11:30:36 +02:00
1 parent d7c0173493
commit 7325c77739
11 files changed
+277 -65

No files matched your search

+86 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:votianlt_app/models/cargo_item.dart';
import 'app_theme.dart'; import 'app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
@@ -81,8 +82,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: ListView(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Job summary card // Job summary card
Card( Card(
@@ -122,11 +122,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: Colors.green[600], color: Colors.green[600],
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Flexible(
widget.job.pickupCity, child: Text(
style: TextStyle( widget.job.pickupCity,
fontSize: 12, style: TextStyle(
color: Colors.grey[700], fontSize: 12,
color: Colors.grey[700],
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -142,13 +144,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary, color: AppColors.primary,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Flexible(
widget.job.deliveryCitiesDisplay.isNotEmpty child: Text(
? widget.job.deliveryCitiesDisplay widget.job.deliveryCitiesDisplay.isNotEmpty
: widget.job.deliveryCity, ? widget.job.deliveryCitiesDisplay
style: TextStyle( : widget.job.deliveryCity,
fontSize: 12, style: TextStyle(
color: Colors.grey[700], fontSize: 12,
color: Colors.grey[700],
),
), ),
), ),
], ],
@@ -157,6 +161,30 @@ class _CargoItemsViewState extends State<CargoItemsView> {
), ),
), ),
), ),
if (widget.job.cargoItems.isNotEmpty) ...[
Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 24,
color: AppColors.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.cargo,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
...widget.job.cargoItems.map(_buildCargoItemCard),
const SizedBox(height: 16),
],
// Delivery stations section header // Delivery stations section header
Row( Row(
children: [ children: [
@@ -166,17 +194,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary, color: AppColors.primary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Expanded(
l10n.deliveryStationsCount(_deliveryStations.length), child: Text(
style: const TextStyle( l10n.deliveryStationsCount(_deliveryStations.length),
fontSize: 20, style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 20,
fontWeight: FontWeight.bold,
),
), ),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded(child: _buildDeliveryStationsList()), _buildDeliveryStationsList(),
], ],
), ),
), ),
@@ -186,6 +216,39 @@ class _CargoItemsViewState extends State<CargoItemsView> {
); );
} }
Widget _buildCargoItemCard(CargoItem item) {
final l10n = AppLocalizations.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.description.trim().isNotEmpty
? item.description
: l10n.cargo,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('${l10n.quantity}: ${item.quantity}'),
if (item.hasDimensions) ...[
const SizedBox(height: 4),
Text('${l10n.dimensions}: ${item.formattedDimensions}'),
],
if (item.weightKg.isFinite && item.weightKg > 0) ...[
const SizedBox(height: 4),
Text('${l10n.weight}: ${item.formattedWeight}'),
],
],
),
),
);
}
List<DeliveryStation> get _deliveryStations { List<DeliveryStation> get _deliveryStations {
if (widget.job.deliveryStations.isNotEmpty) { if (widget.job.deliveryStations.isNotEmpty) {
return widget.job.deliveryStations; return widget.job.deliveryStations;
@@ -238,12 +301,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
); );
} }
return ListView.builder( return Column(
itemCount: _deliveryStations.length, crossAxisAlignment: CrossAxisAlignment.stretch,
itemBuilder: (context, index) { children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
final station = _deliveryStations[index];
return _buildDeliveryStationCard(station);
},
); );
} }
+10 -1
View File
@@ -87,9 +87,18 @@ class CargoItem {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
bool get hasDimensions =>
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
/// Get formatted dimensions string for display /// Get formatted dimensions string for display
String get formattedDimensions { String get formattedDimensions {
return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm'; final dimensions = [lengthCm, widthCm, heightCm].map((value) {
if (!value.isFinite || value <= 0) return '–';
return value == value.truncateToDouble()
? value.toStringAsFixed(0)
: value.toString();
});
return '${dimensions.join(' × ')} cm';
} }
/// Get formatted weight string for display /// Get formatted weight string for display
+1
View File
@@ -1216,6 +1216,7 @@ class WebSocketService {
name: 'WebSocketService', name: 'WebSocketService',
); );
developer.log('Stack: $st', name: 'WebSocketService'); developer.log('Stack: $st', name: 'WebSocketService');
rethrow;
} }
} }
+46 -20
View File
@@ -42,6 +42,9 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> { class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {}; final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
// Store SVG representations of signatures per task for later use // Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {}; final Map<String, String> _signatureSvgByTask = {};
@@ -61,10 +64,19 @@ class _TaskViewState extends State<TaskView> {
.toList(); .toList();
} }
String get _stationCompletionKey =>
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states /// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async { Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses(); final statuses = await _databaseService.loadAllTaskStatuses();
final stationCompleted =
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
if (!mounted) return;
setState(() { setState(() {
_stationCompleted =
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
_statusesLoaded = true;
_completedTasks.clear(); _completedTasks.clear();
// 1) Add all completed from DB // 1) Add all completed from DB
for (final entry in statuses.entries) { for (final entry in statuses.entries) {
@@ -148,9 +160,11 @@ class _TaskViewState extends State<TaskView> {
} }
bool get _canCompleteStation { bool get _canCompleteStation {
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
return false;
}
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben // Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button // erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
// immer aktiv.
for (final t in _visibleTasks) { for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) { if (!t.optional && !_completedTasks.contains(t.id)) {
return false; return false;
@@ -184,6 +198,7 @@ class _TaskViewState extends State<TaskView> {
} }
void _onCompleteStationPressed() { void _onCompleteStationPressed() {
if (!_canCompleteStation) return;
if (_hasIncompleteOptionalTasks) { if (_hasIncompleteOptionalTasks) {
showDialog( showDialog(
context: context, context: context,
@@ -214,21 +229,30 @@ class _TaskViewState extends State<TaskView> {
} }
} }
void _sendStationCompleted() { Future<void> _sendStationCompleted() async {
if (!_canCompleteStation) return;
setState(() => _isCompletingStation = true);
final stationOrder = widget.stationOrder ?? 0; final stationOrder = widget.stationOrder ?? 0;
try { try {
StompService().sendStationCompleted( await StompService().sendStationCompleted(
jobId: widget.job.id, jobId: widget.job.id,
jobNumber: widget.job.jobNumber, jobNumber: widget.job.jobNumber,
stationOrder: stationOrder, stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks, hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
); );
ScaffoldMessenger.of(context).showSnackBar( _stationCompleted = true;
const SnackBar(content: Text('Station abgeschlossen')), await _databaseService.saveKeyValue(_stationCompletionKey, '1');
); if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Station abgeschlossen')));
Navigator.of(context).pop(); Navigator.of(context).pop();
} catch (e) { } catch (e) {
developer.log('Error sending station completion: $e', name: 'TaskView'); developer.log('Error sending station completion: $e', name: 'TaskView');
} finally {
if (mounted) {
setState(() => _isCompletingStation = false);
}
} }
} }
@@ -372,18 +396,18 @@ class _TaskViewState extends State<TaskView> {
child: Align( child: Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 4, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.warningSoft, color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5), color: AppColors.warning.withValues(alpha: 0.5),
width: 1, width: 1,
),
), ),
),
child: const Text( child: const Text(
'Optional', 'Optional',
style: TextStyle( style: TextStyle(
@@ -756,7 +780,7 @@ class _TaskViewState extends State<TaskView> {
// den Benutzer fragen, ob er die Station jetzt abschließen möchte. // den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) { if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted && _canCompleteStation) {
_showLastMandatoryCompletedDialog(); _showLastMandatoryCompletedDialog();
} }
}); });
@@ -899,7 +923,9 @@ class _TaskViewState extends State<TaskView> {
? localizeKnownText(context, task.description!) ? localizeKnownText(context, task.description!)
: null; : null;
final String? signatureNote = final String? signatureNote =
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty) (task is SignatureTask &&
task.note != null &&
task.note!.trim().isNotEmpty)
? task.note!.trim() ? task.note!.trim()
: null; : null;
+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.22+922 version: 0.9.23+923
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.22</revision> <revision>0.9.23</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>
@@ -31,6 +31,10 @@ import de.assecutor.votianlt.model.JobStatus;
import lombok.extern.slf4j.Slf4j; 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.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -53,6 +57,7 @@ public class MessageController {
private final AppUserService appUserService; private final AppUserService appUserService;
private final JobRepository jobRepository; private final JobRepository jobRepository;
private final MongoTemplate mongoTemplate;
private final CargoItemRepository cargoItemRepository; private final CargoItemRepository cargoItemRepository;
@@ -72,11 +77,12 @@ public class MessageController {
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository, TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository, SignatureRepository signatureRepository, CommentRepository commentRepository,
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService, JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
MessageService messageService, TaskAssignmentService taskAssignmentService) { MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) {
this.messagingPublisher = messagingPublisher; this.messagingPublisher = messagingPublisher;
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.appUserService = appUserService; this.appUserService = appUserService;
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.mongoTemplate = mongoTemplate;
this.cargoItemRepository = cargoItemRepository; this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.photoRepository = photoRepository; this.photoRepository = photoRepository;
@@ -420,7 +426,9 @@ public class MessageController {
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted()); boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
if (allCompleted) { if (allCompleted) {
updateJobStatusToCompleted(jobId); if (!updateJobStatusToCompleted(jobId)) {
return;
}
try { try {
emailService.sendJobCompletionNotification(jobId, completedBy); emailService.sendJobCompletionNotification(jobId, completedBy);
} catch (Exception e) { } catch (Exception e) {
@@ -438,23 +446,18 @@ public class MessageController {
} }
} }
private void updateJobStatusToCompleted(ObjectId jobId) { private boolean updateJobStatusToCompleted(ObjectId jobId) {
try { try {
Optional<Job> jobOpt = jobRepository.findById(jobId); Query query = Query.query(Criteria.where("_id").is(jobId).and("status").ne(JobStatus.COMPLETED));
if (jobOpt.isEmpty()) { Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId); boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
return; if (completed) {
}
Job job = jobOpt.get();
if (job.getStatus() != JobStatus.COMPLETED) {
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
log.info("[JOB] Job {} marked as COMPLETED", jobId); log.info("[JOB] Job {} marked as COMPLETED", jobId);
} }
return completed;
} catch (Exception e) { } catch (Exception e) {
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e); log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
return false;
} }
} }
@@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog {
if (copy != null) { if (copy != null) {
copy.setDescription(original.getDescription()); copy.setDescription(original.getDescription());
copy.setOptional(original.isOptional());
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0); copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
copy.setCompleted(original.isCompleted()); copy.setCompleted(original.isCompleted());
copy.setCompletedAt(original.getCompletedAt()); copy.setCompletedAt(original.getCompletedAt());
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear(); customerLabelMap.clear();
for (Customer c : customers) { for (Customer c : customers) {
CustomerAddressLabelHelper.putName(customerLabelMap, c, CustomerAddressLabelHelper.put(customerLabelMap, c,
translationHelper.getTranslation("addjob.customer.unnamed")); translationHelper.getTranslation("addjob.customer.unnamed"));
} }
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet())); customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner(); List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear(); customerLabelToEntity.clear();
for (Customer c : ownerCustomers) { for (Customer c : ownerCustomers) {
CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed")); CustomerAddressLabelHelper.put(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
} }
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet())); customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
@@ -0,0 +1,112 @@
package de.assecutor.votianlt.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.mongodb.client.result.UpdateResult;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.service.EmailService;
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 org.bson.Document;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
@ExtendWith(MockitoExtension.class)
class StationCompletionTest {
@Mock
private JobRepository jobRepository;
@Mock
private TaskAssignmentService taskAssignmentService;
@Mock
private EmailService emailService;
@Mock
private JobUpdateBroadcaster jobUpdateBroadcaster;
@Mock
private MongoTemplate mongoTemplate;
@InjectMocks
private MessageController controller;
private final ObjectId jobId = new ObjectId();
private ConfirmationTask mandatoryTask;
private Map<String, Object> payload;
@BeforeEach
void setUp() {
Job job = new Job();
job.setId(jobId);
job.setJobNumber("JOB-001");
when(jobRepository.findById(jobId)).thenReturn(Optional.of(job));
mandatoryTask = new ConfirmationTask("Lieferung bestätigen");
mandatoryTask.setCompleted(true);
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask));
payload = Map.of("jobId", jobId.toHexString(), "jobNumber", "JOB-001", "stationOrder", 0);
}
@Test
void repeatedStationCompletionSendsOnlyOneEmail() {
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null));
controller.handleStationCompleted("driver-1", payload);
controller.handleStationCompleted("driver-1", payload);
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
}
@Test
void alreadyCompletedJobDoesNotSendAnotherEmail() {
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
.thenReturn(UpdateResult.acknowledged(0, 0L, null));
controller.handleStationCompleted("driver-1", payload);
verifyNoInteractions(emailService);
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
verify(mongoTemplate).updateFirst(query.capture(), any(Update.class), eq(Job.class));
assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId)
.containsEntry("status", new Document("$ne", JobStatus.COMPLETED));
}
@Test
void incompleteMandatoryTaskPreventsJobCompletion() {
mandatoryTask.setCompleted(false);
controller.handleStationCompleted("driver-1", payload);
verifyNoInteractions(mongoTemplate, emailService);
}
@Test
void unfinishedOptionalTaskDoesNotPreventExplicitCompletion() {
ConfirmationTask optionalTask = new ConfirmationTask("Zusätzliche Bestätigung");
optionalTask.setOptional(true);
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(mandatoryTask, optionalTask));
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
controller.handleStationCompleted("driver-1", payload);
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
}
}