fix: Stationsabschluss absichern und Kundenformulare anpassen

Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
This commit is contained in:
Sven committed 2026-10-06 15:09:51 +02:00
1 parent 8758c53e5b
commit 5e6044e4b1
16 files changed
+637 -68

No files matched your search

+15 -2
View File
@@ -6,6 +6,7 @@ import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/delivery_station.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'services/database_service.dart';
import 'task_view.dart';
import 'widgets/offline_banner.dart';
@@ -13,8 +14,10 @@ import 'widgets/offline_banner.dart';
@visibleForTesting
Color? deliveryStationCardBackgroundColor(
DeliveryStation station,
Map<String, bool> taskStatuses,
) {
Map<String, bool> taskStatuses, {
bool isClosed = false,
}) {
if (isClosed) return AppColors.successSoft;
if (station.tasks.isEmpty) {
return null;
}
@@ -37,6 +40,7 @@ class CargoItemsView extends StatefulWidget {
class _CargoItemsViewState extends State<CargoItemsView> {
final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {};
Set<int> _completedStations = const {};
@override
void initState() {
@@ -46,9 +50,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
widget.job,
);
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
@@ -311,6 +319,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
final backgroundColor = deliveryStationCardBackgroundColor(
station,
_taskStatuses,
isClosed: JobProgress(
widget.job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations,
).isStationClosed(station.stationOrder),
);
final title =
station.displayName.isNotEmpty ? station.displayName : station.company;
+53 -20
View File
@@ -8,6 +8,7 @@ import 'services/dart_mq.dart';
import 'services/chat_service.dart';
import 'models/delivery_station.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart';
@@ -23,6 +24,13 @@ import 'services/database_service.dart';
import 'navigation_observer.dart';
import 'routing_view.dart';
@visibleForTesting
Color? jobCardBackgroundColor(JobProgress progress) {
if (progress.isCompleted) return AppColors.successSoft;
if (progress.hasProgress) return AppColors.warningSoft;
return null;
}
class JobsView extends StatefulWidget {
const JobsView({super.key});
@@ -63,6 +71,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
bool _isLoggingOut = false;
final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {};
Map<String, Set<int>> _completedStations = const {};
Map<String, bool> _jobSeen = const {};
Map<String, double> _jobSwipeOffsets = const {};
String? _openJobId;
@@ -479,9 +488,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
final completedStations = <String, Set<int>>{};
for (final job in _appState.assignedJobs) {
completedStations[job.id] = await _databaseService
.loadCompletedStationOrders(job);
}
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
@@ -950,25 +965,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
statusColor = AppColors.textMuted;
}
// Determine card background color based on task completion
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
final totalTasks = job.tasks.length;
int completedTasks = 0;
for (final t in job.tasks) {
final isCompleted = _taskStatuses[t.id] ?? t.completed;
if (isCompleted) completedTasks++;
}
// Check if all tasks are completed (job is done)
final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks;
Color? cardBg;
if (totalTasks == 0 || completedTasks == 0) {
cardBg = null; // unchanged (default)
} else if (completedTasks > 0 && completedTasks < totalTasks) {
cardBg = AppColors.warningSoft;
} else if (completedTasks == totalTasks) {
cardBg = AppColors.successSoft;
}
final completedTasks = progress.completedTaskCount;
final isJobCompleted = progress.isCompleted;
final cardBg = jobCardBackgroundColor(progress);
// Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
final pickupDisplayName =
@@ -1211,7 +1216,17 @@ class _JobsViewState extends State<JobsView> with RouteAware {
),
],
),
// Progress bar for tasks
if (isJobCompleted) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).jobCompleted,
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w600,
),
),
],
// Progress bar for tasks (optional tasks retain their actual state).
if (totalTasks > 0) ...[
const SizedBox(height: 8),
Text(
@@ -1235,6 +1250,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
minHeight: 8,
backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>(
isJobCompleted ||
completedTasks >= totalTasks
? AppColors.success
: (completedTasks > 0
@@ -1447,6 +1463,14 @@ class _JobsViewState extends State<JobsView> with RouteAware {
}
void _toggleTaskCompletion(Job job, int taskIndex) {
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) {
return;
}
// Create a new task with toggled completion status
final updatedTask = job.tasks[taskIndex].copyWith(
completed: !job.tasks[taskIndex].completed,
@@ -1488,6 +1512,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
id: job.id,
jobNumber: job.jobNumber,
status: job.status,
completedStationOrders: job.completedStationOrders,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
createdBy: job.createdBy,
@@ -1690,7 +1715,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
const SizedBox(height: 8),
...job.tasks.asMap().entries.map(
(entry) => GestureDetector(
onTap: () => _toggleTaskCompletion(job, entry.key),
onTap:
JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations:
_completedStations[job.id] ?? const {},
).isStationClosed(entry.value.stationOrder ?? 0)
? null
: () => _toggleTaskCompletion(job, entry.key),
child: Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
+1 -1
View File
@@ -52,7 +52,7 @@ class DeliveryStation {
final tasks =
(json['tasks'] as List? ?? const []).map((rawTask) {
final taskJson = Map<String, dynamic>.from(rawTask as Map);
taskJson['stationOrder'] ??= stationOrder;
taskJson['stationOrder'] = stationOrder;
return Task.fromJson(taskJson);
}).toList()
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
+10
View File
@@ -6,6 +6,7 @@ class Job {
final String id; // Will store the timestamp as string
final String jobNumber;
final String status;
final List<int> completedStationOrders;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
@@ -61,6 +62,7 @@ class Job {
required this.id,
required this.jobNumber,
required this.status,
this.completedStationOrders = const [],
required this.createdAt,
required this.updatedAt,
required this.createdBy,
@@ -332,6 +334,12 @@ class Job {
id: idValue,
jobNumber: jobJson['jobNumber']?.toString() ?? '',
status: jobJson['status']?.toString() ?? 'UNKNOWN',
completedStationOrders:
(jobJson['completedStationOrders'] as List? ?? const [])
.map((value) => int.tryParse(value.toString()))
.whereType<int>()
.toSet()
.toList(),
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
createdBy: jobJson['createdBy']?.toString() ?? '',
@@ -391,6 +399,7 @@ class Job {
id: id,
jobNumber: t(jobNumber),
status: t(status),
completedStationOrders: List.of(completedStationOrders),
createdAt: createdAt,
updatedAt: updatedAt,
createdBy: t(createdBy),
@@ -446,6 +455,7 @@ class Job {
'id': id,
'jobNumber': jobNumber,
'status': status,
'completedStationOrders': completedStationOrders,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'createdBy': createdBy,
+52
View File
@@ -0,0 +1,52 @@
import 'job.dart';
import 'task.dart';
/// Combines server state with work confirmed locally while offline.
class JobProgress {
final Job job;
final Map<String, bool> taskStatuses;
final Set<int> completedStationOrders;
JobProgress(
this.job, {
this.taskStatuses = const {},
Set<int> localCompletedStations = const {},
}) : completedStationOrders = {
...job.completedStationOrders,
...localCompletedStations,
};
bool isTaskCompleted(Task task) =>
task.completed || taskStatuses[task.id] == true;
bool get isCancelled => job.status.toUpperCase() == 'CANCELLED';
bool get isCompleted {
if (isCancelled) return false;
if (const {'COMPLETED', 'DONE'}.contains(job.status.toUpperCase())) {
return true;
}
// Same rule as the backend: explicit final-station confirmation and all
// mandatory tasks completed. Open optional tasks do not prevent completion.
final lastStationOrder =
job.deliveryStations.isEmpty
? 0
: job.deliveryStations
.map((station) => station.stationOrder)
.reduce((a, b) => a > b ? a : b);
return completedStationOrders.contains(lastStationOrder) &&
job.tasks.where((task) => !task.optional).every(isTaskCompleted);
}
bool isStationClosed(int stationOrder) =>
isCancelled ||
isCompleted ||
completedStationOrders.contains(stationOrder);
bool canCompleteTask(Task task) =>
!isStationClosed(task.stationOrder ?? 0) && !isTaskCompleted(task);
int get completedTaskCount => job.tasks.where(isTaskCompleted).length;
bool get hasProgress =>
completedTaskCount > 0 || completedStationOrders.isNotEmpty;
}
+17
View File
@@ -368,6 +368,23 @@ class DatabaseService {
}
}
/// Merge server confirmations and locally persisted offline confirmations.
Future<Set<int>> loadCompletedStationOrders(Job job) async {
final orders =
job.deliveryStations.isEmpty
? {0}
: job.deliveryStations
.map((station) => station.stationOrder)
.toSet();
final completed = <int>{...job.completedStationOrders};
for (final order in orders) {
if (await loadKeyValue('station_completed:${job.id}:$order') == '1') {
completed.add(order);
}
}
return completed;
}
/// Load all task completion statuses
Future<Map<String, bool>> loadAllTaskStatuses() async {
try {
+74 -15
View File
@@ -6,9 +6,11 @@ import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'app_state.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart';
@@ -42,6 +44,9 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService();
late Job _job;
Set<int> _localCompletedStations = {};
StreamSubscription<void>? _jobsUpdatedSubscription;
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
@@ -51,31 +56,73 @@ class _TaskViewState extends State<TaskView> {
@override
void initState() {
super.initState();
_job = widget.job;
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
(_) => _loadTaskStatuses(),
);
_loadTaskStatuses();
}
@override
void didUpdateWidget(covariant TaskView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.job, widget.job) ||
oldWidget.stationOrder != widget.stationOrder) {
_job = widget.job;
_statusesLoaded = false;
_loadTaskStatuses();
}
}
@override
void dispose() {
_jobsUpdatedSubscription?.cancel();
super.dispose();
}
JobProgress get _progress => JobProgress(
_job,
taskStatuses: {for (final id in _completedTasks) id: true},
localCompletedStations: _localCompletedStations,
);
bool _canCompleteTask(Task task) =>
mounted &&
_statusesLoaded &&
!_isCompletingStation &&
!_stationCompleted &&
_progress.canCompleteTask(task);
List<Task> get _visibleTasks {
final stationOrder = widget.stationOrder;
if (stationOrder == null) {
return widget.job.tasks;
return _job.tasks;
}
return widget.job.tasks
return _job.tasks
.where((task) => task.stationOrder == stationOrder)
.toList();
}
String get _stationCompletionKey =>
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}';
'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
var latestJob = _job;
for (final savedJob in await _databaseService.loadJobs()) {
if (savedJob.id == _job.id &&
!savedJob.updatedAt.isBefore(latestJob.updatedAt)) {
latestJob = savedJob;
}
}
final statuses = await _databaseService.loadAllTaskStatuses();
final stationCompleted =
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
final completedStations = await _databaseService.loadCompletedStationOrders(
latestJob,
);
if (!mounted) return;
setState(() {
_stationCompleted =
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
_job = latestJob;
_localCompletedStations = completedStations;
_statusesLoaded = true;
_completedTasks.clear();
// 1) Add all completed from DB
@@ -85,11 +132,12 @@ class _TaskViewState extends State<TaskView> {
}
}
// 2) Merge: also mark tasks completed if the job JSON already had them completed
for (final t in widget.job.tasks) {
for (final t in _job.tasks) {
if (t.completed) {
_completedTasks.add(t.id);
}
}
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
});
}
@@ -100,7 +148,7 @@ class _TaskViewState extends State<TaskView> {
title: Text(
widget.stationTitle?.isNotEmpty == true
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
: '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -189,7 +237,9 @@ class _TaskViewState extends State<TaskView> {
child: ElevatedButton.icon(
onPressed: enabled ? _onCompleteStationPressed : null,
icon: const Icon(Icons.flag),
label: const Text('Station abschließen'),
label: Text(
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
@@ -235,12 +285,13 @@ class _TaskViewState extends State<TaskView> {
final stationOrder = widget.stationOrder ?? 0;
try {
await StompService().sendStationCompleted(
jobId: widget.job.id,
jobNumber: widget.job.jobNumber,
jobId: _job.id,
jobNumber: _job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
_stationCompleted = true;
_localCompletedStations.add(stationOrder);
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
if (!mounted) return;
ScaffoldMessenger.of(
@@ -289,7 +340,7 @@ class _TaskViewState extends State<TaskView> {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final canBeCompletedNow =
!isCompleted && _arePreviousTasksCompleted(index);
_canCompleteTask(task) && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
@@ -429,6 +480,7 @@ class _TaskViewState extends State<TaskView> {
}
void _showTaskCompletionDialog(Task task, int taskIndex) {
if (!_canCompleteTask(task)) return;
switch (task) {
case ConfirmationTask():
_showConfirmationDialog(task, taskIndex);
@@ -542,10 +594,12 @@ class _TaskViewState extends State<TaskView> {
(context) => PhotoCaptureScreen(
task: task,
onPhotosCompleted: (List<Uint8List> photoData) async {
if (!_canCompleteTask(task)) return;
// Compress + encode photos for network send (limit payload)
final List<String> base64List = await _compressAndEncodePhotos(
photoData,
);
if (!_canCompleteTask(task)) return;
final bool truncated = base64List.length < photoData.length;
// Try to persist full-quality (encoded) photos to DB for offline/backup
@@ -647,6 +701,7 @@ class _TaskViewState extends State<TaskView> {
(context) => SignatureCaptureScreen(
task: task,
onSignatureCompleted: (String svg, String note) async {
if (!_canCompleteTask(task)) return;
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
@@ -658,6 +713,7 @@ class _TaskViewState extends State<TaskView> {
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
if (!_canCompleteTask(task)) return;
// Store SVG for later use in this TaskView session
setState(() {
_signatureSvgByTask[task.id] = svg;
@@ -690,6 +746,7 @@ class _TaskViewState extends State<TaskView> {
(context) => BarcodeCaptureScreen(
task: task,
onBarcodesCompleted: (List<String> barcodes) async {
if (!_canCompleteTask(task)) return;
try {
// Save barcodes to database for later use
await _databaseService.saveTaskBarcodes(task.id, barcodes);
@@ -758,6 +815,8 @@ class _TaskViewState extends State<TaskView> {
String? taskType,
Map<String, dynamic>? extraData,
}) {
final tasks = _visibleTasks.where((task) => task.id == taskId);
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() {
_completedTasks.add(taskId);
@@ -1000,7 +1059,7 @@ class _TaskViewState extends State<TaskView> {
}
}
String _getRemark() => widget.job.remark;
String _getRemark() => _job.remark;
String? _getTaskStationLabel(Task task) {
if (widget.stationOrder != null) {
@@ -1011,7 +1070,7 @@ class _TaskViewState extends State<TaskView> {
return null;
}
for (final station in widget.job.deliveryStations) {
for (final station in _job.deliveryStations) {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
+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.24+924
version: 0.9.25+925
environment:
sdk: ^3.7.0
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
Map<String, dynamic> task(
String id,
int order, {
bool completed = false,
bool optional = false,
}) => {
'id': id,
'jobId': 'job-1',
'stationOrder': order,
'completed': completed,
'optional': optional,
'taskSpecificData': {'taskType': 'CONFIRMATION', 'buttonText': 'Bestätigen'},
};
Job exampleJob({
String status = 'IN_PROGRESS',
List<Object> completedStations = const [],
bool mandatoryDone = true,
}) => Job.fromJson({
'id': 'job-1',
'status': status,
'completedStationOrders': completedStations,
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
task('required', 0, completed: mandatoryDone),
task('optional', 0, optional: true),
],
},
{
'stationOrder': 2,
'tasks': [task('final', 2, completed: true)],
},
],
});
void main() {
test(
'closed intermediate station locks optional tasks but leaves the next station open',
() {
final job = exampleJob(completedStations: [0]);
final progress = JobProgress(job);
expect(progress.isCompleted, isFalse);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(progress.isStationClosed(0), isTrue);
expect(progress.isStationClosed(2), isFalse);
},
);
test(
'server completion is authoritative despite unfinished optional tasks',
() {
final job = exampleJob(status: 'COMPLETED');
final progress = JobProgress(job);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(job.tasks[1].completed, isFalse);
},
);
test(
'all mandatory tasks alone do not complete the job without station confirmation',
() {
expect(JobProgress(exampleJob()).isCompleted, isFalse);
},
);
test(
'final station confirmation completes the job even with optional tasks open',
() {
expect(
JobProgress(exampleJob(completedStations: [0, 2])).isCompleted,
isTrue,
);
},
);
test(
'offline final station confirmation uses locally completed mandatory tasks',
() {
final progress = JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {0, 2},
taskStatuses: {'required': true},
);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
},
);
test(
'final confirmation cannot bypass unfinished mandatory work at an earlier station',
() {
expect(
JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {2},
).isCompleted,
isFalse,
);
},
);
test(
'completed station confirmations survive normalization and JSON storage',
() {
final saved =
exampleJob(
completedStations: [0, '2', 2, 'invalid'],
).normalized().toJson();
final restored = Job.fromJson(saved);
expect(restored.completedStationOrders, [0, 2]);
expect(JobProgress(restored).isStationClosed(0), isTrue);
},
);
test(
'legacy job with no stations completes only after explicit station zero confirmation',
() {
final job = Job.fromJson({
'id': 'legacy',
'status': 'IN_PROGRESS',
'tasks': [task('optional', 0, optional: true)],
});
expect(JobProgress(job).isCompleted, isFalse);
expect(JobProgress(job, localCompletedStations: {0}).isCompleted, isTrue);
},
);
test('cancelled jobs remain locked and are not reported as completed', () {
final progress = JobProgress(
exampleJob(status: 'CANCELLED'),
localCompletedStations: {0, 2},
);
expect(progress.isCompleted, isFalse);
expect(progress.isStationClosed(2), isTrue);
});
test('embedded station order takes precedence over a stale task order', () {
final job = Job.fromJson({
'id': 'job-1',
'completedStationOrders': [0],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [task('optional', 8, optional: true)],
},
{'stationOrder': 1, 'tasks': []},
],
});
expect(job.tasks.single.stationOrder, 0);
expect(JobProgress(job).canCompleteTask(job.tasks.single), isFalse);
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/app_theme.dart';
import 'package:votianlt_app/cargo_items_view.dart';
import 'package:votianlt_app/jobs_view.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
import 'package:votianlt_app/task_view.dart';
Job jobWithOptionalTask({
bool closedStation = true,
String status = 'IN_PROGRESS',
}) => Job.fromJson({
'id': 'job-1',
'jobNumber': 'TEST-1',
'status': status,
'completedStationOrders': closedStation ? [0] : [],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
{
'id': 'optional',
'jobId': 'job-1',
'optional': true,
'completed': false,
'description': 'Optionale Bestätigung',
'taskOrder': 0,
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Jetzt bestätigen',
},
},
],
},
{'stationOrder': 1, 'tasks': []},
],
});
void main() {
for (final status in ['IN_PROGRESS', 'COMPLETED']) {
testWidgets(
'optional task cannot open a dialog after station closure ($status)',
(tester) async {
final job = jobWithOptionalTask(status: status);
await tester.pumpWidget(
MaterialApp(home: TaskView(job: job, stationOrder: 0)),
);
await tester.pump();
final task = find.text('Optionale Bestätigung');
expect(task, findsOneWidget);
final tile = tester.widget<InkWell>(
find.ancestor(of: task, matching: find.byType(InkWell)).first,
);
expect(tile.onTap, isNull);
await tester.tap(task);
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
testWidgets('optional task remains executable at an open station', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: TaskView(
job: jobWithOptionalTask(closedStation: false),
stationOrder: 0,
),
),
);
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Jetzt bestätigen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'an already open dialog cannot complete a task after the job closes',
(tester) async {
Widget view(Job job) => MaterialApp(
home: TaskView(
key: const ValueKey('task-view'),
job: job,
stationOrder: 0,
),
);
await tester.pumpWidget(view(jobWithOptionalTask(closedStation: false)));
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.pumpWidget(view(jobWithOptionalTask(status: 'COMPLETED')));
await tester.pump();
await tester.tap(find.text('Jetzt bestätigen'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
final taskCard = tester.widget<Card>(
find
.ancestor(
of: find.text('Optionale Bestätigung'),
matching: find.byType(Card),
)
.first,
);
expect(
taskCard.elevation,
0,
); // still open and locked, never marked completed
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
test('completed job card is green with optional tasks still open', () {
final job = jobWithOptionalTask(status: 'COMPLETED');
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.successSoft);
expect(job.tasks.single.completed, isFalse);
});
test('offline final station completion makes the job card green', () {
final job = jobWithOptionalTask();
expect(
jobCardBackgroundColor(JobProgress(job, localCompletedStations: {1})),
AppColors.successSoft,
);
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.warningSoft);
expect(
deliveryStationCardBackgroundColor(
job.deliveryStations.first,
{},
isClosed: true,
),
AppColors.successSoft,
);
});
}
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.25</revision>
<revision>0.9.26</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
Binary file not shown.
@@ -191,6 +191,17 @@ public class MessageController {
}
public void handleTaskCompleted(Map<String, Object> payload, String taskType, String appUserId) {
Object taskId = payload != null ? payload.get("taskId") : null;
if (taskId == null || !ObjectId.isValid(taskId.toString())) {
return;
}
Optional<BaseTask> task = taskRepository.findById(new ObjectId(taskId.toString()));
if (task.isEmpty() || taskAssignmentService.findJobForTask(task.get())
.filter(job -> canCompleteTask(task.get(), job)).isEmpty()) {
// Check before dispatch: photo/signature/barcode/comment handlers also
// persist attachments before completing the task itself.
return;
}
String key = taskType == null ? "" : taskType.trim().toUpperCase();
switch (key) {
@@ -378,15 +389,14 @@ public class MessageController {
return;
}
BaseTask task = opt.get();
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) {
return;
}
task.setCompletedBy(resolveTaskActor(task, appUserId));
task.setCompleted(true);
task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task);
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty()) {
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
return;
}
ObjectId jobId = jobOpt.get().getId();
// Log detailed task completion in job history
@@ -421,6 +431,19 @@ public class MessageController {
}
}
private boolean canCompleteTask(BaseTask task, Job job) {
if (task.isCompleted() || job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED) {
return false;
}
int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
if (task.getStationId() != null && job.getDeliveryStations() != null) {
stationOrder = job.getDeliveryStations().stream().filter(Objects::nonNull)
.filter(station -> task.getStationId().equals(station.getStationId()))
.mapToInt(DeliveryStation::getStationOrder).findFirst().orElse(stationOrder);
}
return job.getCompletedStationOrders() == null || !job.getCompletedStationOrders().contains(stationOrder);
}
private String resolveTaskActor(BaseTask task, String appUserId) {
if (appUserId != null && !appUserId.isBlank()) {
return appUserId;
@@ -52,15 +52,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
companyName = new TextField(getTranslation("profile.company"));
companyName.setWidthFull();
// Steuernummer (Pflichtfeld)
// Steuernummer (optional)
taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
taxNumber.setRequiredIndicatorVisible(true);
taxNumber.setWidthFull();
taxNumber.addBlurListener(e -> validateField(taxNumber));
// USt-IdNr. (Pflichtfeld)
// USt-IdNr. (optional)
ustId = new TextField(getTranslation("profile.billing.ustid"));
ustId.setRequiredIndicatorVisible(true);
ustId.setPlaceholder("DE123456789");
ustId.setWidthFull();
ustId.addBlurListener(e -> validateUstId());
@@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void configureBinder() {
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
binder.forField(ustId)
.withValidator(value -> value == null || value.isBlank()
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
@@ -265,15 +262,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
/**
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
* Prüft eine angegebene USt-IdNr. auf gültiges Format
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
*/
private void validateUstId() {
String value = ustId.getValue();
if (value == null || value.trim().isEmpty()) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
} else {
@@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
private boolean validateAllFields() {
validateField(taxNumber);
validateUstId();
validateField(firstName);
validateField(lastName);
@@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city);
validateEmail();
return !taxNumber.isInvalid() && !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
return !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
&& !zip.isInvalid() && !city.isInvalid();
}
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H2;
@@ -34,7 +35,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private final Binder<Customer> binder = new Binder<>(Customer.class);
// Form fields - labels set in constructor via setLabel()
private final TextField titleField = new TextField();
private final ComboBox<String> titleField = new ComboBox<>();
private final TextField companyNameField = new TextField();
private final TextField taxNumberField = new TextField();
private final TextField ustIdField = new TextField();
@@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
// Set field labels via i18n
titleField.setLabel(getTranslation("addjob.address.salutation"));
titleField.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"),
getTranslation("addjob.salutation.other"));
titleField.setPlaceholder(getTranslation("addjob.address.salutation.placeholder"));
companyNameField.setLabel(getTranslation("profile.company"));
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.setRequiredIndicatorVisible(true);
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setRequiredIndicatorVisible(true);
ustIdField.setPlaceholder("DE123456789");
firstnameField.setLabel(getTranslation("profile.firstname"));
lastNameField.setLabel(getTranslation("profile.lastname"));
@@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private void setupBinder() {
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustIdField)
.withValidator(value -> value == null || value.isBlank()
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
@@ -5,9 +5,14 @@ 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.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.BarcodeTask;
import de.assecutor.votianlt.model.task.CommentTask;
@@ -73,6 +78,7 @@ class TaskCompletionHistoryTest {
task.setCompletedBy("Unknown");
Job job = new Job();
job.setId(new ObjectId());
job.setCompletedStationOrders(List.of(1));
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"),
@@ -100,4 +106,60 @@ class TaskCompletionHistoryTest {
verify(commentRepository).save(argThat(comment -> actor.equals(comment.getCompletedBy())));
}
}
@ParameterizedTest
@MethodSource("tasks")
void optionalTasksOfClosedStationCannotPersistDataOrSendEmail(BaseTask task) {
task.setId(new ObjectId());
task.setOptional(true);
task.setStationOrder(0);
Job job = new Job();
job.setCompletedStationOrders(List.of(0));
rejectCompletion(task, job);
}
@ParameterizedTest
@MethodSource("tasks")
void completedJobRejectsTasksEvenWithoutStationConfirmation(BaseTask task) {
task.setId(new ObjectId());
task.setOptional(true);
Job job = new Job();
job.setStatus(JobStatus.COMPLETED);
rejectCompletion(task, job);
}
@ParameterizedTest
@MethodSource("tasks")
void repeatedCompletionCannotAddAttachmentsOrSendAnotherEmail(BaseTask task) {
task.setId(new ObjectId());
task.setCompleted(true);
rejectCompletion(task, new Job());
}
@ParameterizedTest
@MethodSource("tasks")
void closedStationIsResolvedByIdEvenWhenTaskOrderIsStale(BaseTask task) {
task.setId(new ObjectId());
task.setStationId(new ObjectId());
task.setStationOrder(7);
DeliveryStation station = new DeliveryStation();
station.setStationId(task.getStationId());
station.setStationOrder(0);
Job job = new Job();
job.setDeliveryStations(List.of(station));
job.setCompletedStationOrders(List.of(0));
rejectCompletion(task, job);
}
private void rejectCompletion(BaseTask task, Job job) {
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/>", "commentText", "Late comment");
controller.handleTaskCompleted(Map.of("taskId", task.getIdAsString(), "extraData", extra),
task.getTaskType(), "driver-1");
verify(taskRepository, never()).save(any());
verifyNoInteractions(photoRepository, signatureRepository, barcodeRepository, commentRepository,
emailService, jobHistoryService, jobUpdateBroadcaster);
}
}