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:votianlt_app/models/cargo_item.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
@@ -81,8 +82,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: ListView(
children: [
// Job summary card
Card(
@@ -122,11 +122,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: Colors.green[600],
),
const SizedBox(width: 4),
Text(
widget.job.pickupCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
Flexible(
child: Text(
widget.job.pickupCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
const SizedBox(width: 8),
@@ -142,13 +144,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
widget.job.deliveryCitiesDisplay.isNotEmpty
? widget.job.deliveryCitiesDisplay
: widget.job.deliveryCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
Flexible(
child: Text(
widget.job.deliveryCitiesDisplay.isNotEmpty
? widget.job.deliveryCitiesDisplay
: widget.job.deliveryCity,
style: TextStyle(
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
Row(
children: [
@@ -166,17 +194,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
Expanded(
child: Text(
l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
],
),
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 {
if (widget.job.deliveryStations.isNotEmpty) {
return widget.job.deliveryStations;
@@ -238,12 +301,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
);
}
return ListView.builder(
itemCount: _deliveryStations.length,
itemBuilder: (context, index) {
final station = _deliveryStations[index];
return _buildDeliveryStationCard(station);
},
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
);
}
+10 -1
View File
@@ -87,9 +87,18 @@ class CargoItem {
@override
int get hashCode => id.hashCode;
bool get hasDimensions =>
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
/// Get formatted dimensions string for display
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
+1
View File
@@ -1216,6 +1216,7 @@ class WebSocketService {
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> {
final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService();
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@@ -61,10 +64,19 @@ class _TaskViewState extends State<TaskView> {
.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
Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses();
final stationCompleted =
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
if (!mounted) return;
setState(() {
_stationCompleted =
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
_statusesLoaded = true;
_completedTasks.clear();
// 1) Add all completed from DB
for (final entry in statuses.entries) {
@@ -148,9 +160,11 @@ class _TaskViewState extends State<TaskView> {
}
bool get _canCompleteStation {
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
return false;
}
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
// immer aktiv.
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
@@ -184,6 +198,7 @@ class _TaskViewState extends State<TaskView> {
}
void _onCompleteStationPressed() {
if (!_canCompleteStation) return;
if (_hasIncompleteOptionalTasks) {
showDialog(
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;
try {
StompService().sendStationCompleted(
await StompService().sendStationCompleted(
jobId: widget.job.id,
jobNumber: widget.job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Station abgeschlossen')),
);
_stationCompleted = true;
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Station abgeschlossen')));
Navigator.of(context).pop();
} catch (e) {
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(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
),
),
),
child: const Text(
'Optional',
style: TextStyle(
@@ -756,7 +780,7 @@ class _TaskViewState extends State<TaskView> {
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (mounted && _canCompleteStation) {
_showLastMandatoryCompletedDialog();
}
});
@@ -899,7 +923,9 @@ class _TaskViewState extends State<TaskView> {
? localizeKnownText(context, task.description!)
: null;
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()
: 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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.9.22+922
version: 0.9.23+923
environment:
sdk: ^3.7.0
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.22</revision>
<revision>0.9.23</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -31,6 +31,10 @@ import de.assecutor.votianlt.model.JobStatus;
import lombok.extern.slf4j.Slf4j;
import de.assecutor.votianlt.messaging.MessagingPublisher;
import org.springframework.stereotype.Component;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import java.time.LocalDateTime;
import java.util.List;
@@ -53,6 +57,7 @@ public class MessageController {
private final AppUserService appUserService;
private final JobRepository jobRepository;
private final MongoTemplate mongoTemplate;
private final CargoItemRepository cargoItemRepository;
@@ -72,11 +77,12 @@ public class MessageController {
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
SignatureRepository signatureRepository, CommentRepository commentRepository,
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
MessageService messageService, TaskAssignmentService taskAssignmentService) {
MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) {
this.messagingPublisher = messagingPublisher;
this.appUserRepository = appUserRepository;
this.appUserService = appUserService;
this.jobRepository = jobRepository;
this.mongoTemplate = mongoTemplate;
this.cargoItemRepository = cargoItemRepository;
this.taskRepository = taskRepository;
this.photoRepository = photoRepository;
@@ -420,7 +426,9 @@ public class MessageController {
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
if (allCompleted) {
updateJobStatusToCompleted(jobId);
if (!updateJobStatusToCompleted(jobId)) {
return;
}
try {
emailService.sendJobCompletionNotification(jobId, completedBy);
} catch (Exception e) {
@@ -438,23 +446,18 @@ public class MessageController {
}
}
private void updateJobStatusToCompleted(ObjectId jobId) {
private boolean updateJobStatusToCompleted(ObjectId jobId) {
try {
Optional<Job> jobOpt = jobRepository.findById(jobId);
if (jobOpt.isEmpty()) {
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
return;
}
Job job = jobOpt.get();
if (job.getStatus() != JobStatus.COMPLETED) {
job.setStatus(JobStatus.COMPLETED);
job.setUpdatedAt(LocalDateTime.now());
jobRepository.save(job);
Query query = Query.query(Criteria.where("_id").is(jobId).and("status").ne(JobStatus.COMPLETED));
Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
if (completed) {
log.info("[JOB] Job {} marked as COMPLETED", jobId);
}
return completed;
} catch (Exception 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) {
copy.setDescription(original.getDescription());
copy.setOptional(original.isOptional());
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
copy.setCompleted(original.isCompleted());
copy.setCompletedAt(original.getCompletedAt());
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear();
for (Customer c : customers) {
CustomerAddressLabelHelper.putName(customerLabelMap, c,
CustomerAddressLabelHelper.put(customerLabelMap, c,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
customerLabelToEntity.clear();
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()));
@@ -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");
}
}