Compare commits
2
Commits
d7c0173493
...
b642f42954
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b642f42954 | ||
|
|
7325c77739 |
No files matched your search
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,10 +120,10 @@ class _ChatsViewState extends State<ChatsView> {
|
||||
title: Text(() {
|
||||
if (isJobChat) {
|
||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||
return 'Job $jobNumber';
|
||||
return 'Auftrag $jobNumber';
|
||||
}
|
||||
if (showJobId) {
|
||||
return 'Job $jobId';
|
||||
return 'Auftrag $jobId';
|
||||
}
|
||||
}
|
||||
return localizedChatTitle(context, chat);
|
||||
@@ -154,7 +154,7 @@ class _ChatsViewState extends State<ChatsView> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isJobChat ? 'JOB' : 'ALLG',
|
||||
isJobChat ? 'AUFTRAG' : 'ALLG',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
+2
-23
@@ -145,7 +145,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
if (mounted) {
|
||||
final message =
|
||||
jobNumber != null
|
||||
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||
? 'Auftrag $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||
: AppLocalizations.of(context).jobRemoved;
|
||||
_showSnack(message, backgroundColor: AppColors.warning);
|
||||
}
|
||||
@@ -1406,7 +1406,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Dates and Price
|
||||
// Creation date
|
||||
Row(
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -52,7 +52,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
// ==================== NAVIGATION ====================
|
||||
@override
|
||||
String get jobs => 'Jobs';
|
||||
String get jobs => 'Aufträge';
|
||||
|
||||
@override
|
||||
String get availableJobs => 'Auftragsliste';
|
||||
@@ -155,10 +155,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
// ==================== JOBS ====================
|
||||
@override
|
||||
String get noJobsAssigned => 'Keine Jobs zugewiesen';
|
||||
String get noJobsAssigned => 'Keine Aufträge zugewiesen';
|
||||
|
||||
@override
|
||||
String get noJobsMessage => 'Ihre zugewiesenen Jobs werden hier angezeigt.';
|
||||
String get noJobsMessage =>
|
||||
'Ihre zugewiesenen Aufträge werden hier angezeigt.';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren';
|
||||
@@ -206,13 +207,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get dimensions => 'Abmessungen';
|
||||
|
||||
@override
|
||||
String get jobDeleted => 'Job gelöscht';
|
||||
String get jobDeleted => 'Auftrag gelöscht';
|
||||
|
||||
@override
|
||||
String get jobDeleteError => 'Fehler beim Löschen des Jobs';
|
||||
String get jobDeleteError => 'Fehler beim Löschen des Auftrags';
|
||||
|
||||
@override
|
||||
String get jobCompleted => 'Job abgeschlossen';
|
||||
String get jobCompleted => 'Auftrag abgeschlossen';
|
||||
|
||||
@override
|
||||
String get from => 'Von';
|
||||
@@ -221,10 +222,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get to => 'nach';
|
||||
|
||||
@override
|
||||
String get jobsUpdated => 'Jobs aktualisiert';
|
||||
String get jobsUpdated => 'Aufträge aktualisiert';
|
||||
|
||||
@override
|
||||
String get connectionRestored => 'Verbindung wiederhergestellt. Lade Jobs...';
|
||||
String get connectionRestored =>
|
||||
'Verbindung wiederhergestellt. Lade Aufträge...';
|
||||
|
||||
@override
|
||||
String get connectionLost => 'Verbindung verloren. Offline.';
|
||||
@@ -233,13 +235,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get deleteJob => 'Job löschen';
|
||||
String get deleteJob => 'Auftrag löschen';
|
||||
|
||||
@override
|
||||
String get jobRemoved => 'wurde entfernt';
|
||||
|
||||
@override
|
||||
String get newJobReceived => 'Neuer Job erhalten';
|
||||
String get newJobReceived => 'Neuer Auftrag erhalten';
|
||||
|
||||
@override
|
||||
String get jobDetails => 'Auftragsdetails';
|
||||
@@ -258,7 +260,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get noDeliveryStationsMessage =>
|
||||
'Dieser Job enthält aktuell keine Lieferstationen.';
|
||||
'Dieser Auftrag enthält aktuell keine Lieferstationen.';
|
||||
|
||||
@override
|
||||
String get phone => 'Telefon';
|
||||
@@ -277,7 +279,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get noTasks => 'Keine Aufgaben';
|
||||
|
||||
@override
|
||||
String get noTasksMessage => 'Für diesen Job sind keine Aufgaben definiert.';
|
||||
String get noTasksMessage =>
|
||||
'Für diesen Auftrag sind keine Aufgaben definiert.';
|
||||
|
||||
@override
|
||||
String get taskOrder => 'Reihenfolge';
|
||||
@@ -498,13 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get imageSendError => 'Bild konnte nicht gesendet werden.';
|
||||
|
||||
@override
|
||||
String get chatTypeJob => 'Job-spezifisch';
|
||||
String get chatTypeJob => 'Auftragsbezogen';
|
||||
|
||||
@override
|
||||
String get chatTypeGeneral => 'Allgemein';
|
||||
|
||||
@override
|
||||
String get jobNumber => 'Job-Nummer';
|
||||
String get jobNumber => 'Auftragsnummer';
|
||||
|
||||
@override
|
||||
String get messages => 'Nachrichten';
|
||||
@@ -558,7 +561,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get noCargoItemsMessage =>
|
||||
'Für diesen Job sind keine Frachtgüter definiert.';
|
||||
'Für diesen Auftrag sind keine Frachtgüter definiert.';
|
||||
|
||||
@override
|
||||
String get article => 'Artikel';
|
||||
|
||||
@@ -46,5 +46,12 @@ String localizedChatTitle(BuildContext context, Chat chat) {
|
||||
if (chat.type == ChatType.general) {
|
||||
return AppLocalizations.of(context).generalMessages;
|
||||
}
|
||||
// Older locally stored conversations still use the previous title prefix.
|
||||
if (chat.title.startsWith('Job ')) {
|
||||
return 'Auftrag ${chat.title.substring(4)}';
|
||||
}
|
||||
if (chat.title == 'Job-Chat') {
|
||||
return 'Auftrags-Chat';
|
||||
}
|
||||
return localizeKnownText(context, chat.title);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -98,7 +98,7 @@ class Chat {
|
||||
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
||||
return Chat(
|
||||
id: id,
|
||||
title: 'Job $jobNumber',
|
||||
title: 'Auftrag $jobNumber',
|
||||
receiver: receiver,
|
||||
type: ChatType.jobSpecific,
|
||||
jobId: jobId,
|
||||
|
||||
@@ -109,7 +109,7 @@ class Job {
|
||||
this.assignedTo,
|
||||
this.location,
|
||||
this.additionalData,
|
||||
}) : title = title ?? 'Job $jobNumber',
|
||||
}) : title = title ?? 'Auftrag $jobNumber',
|
||||
description =
|
||||
description ?? 'Transport von $pickupCity nach $deliveryCity';
|
||||
|
||||
|
||||
@@ -350,12 +350,12 @@ class ChatService {
|
||||
|
||||
String _buildJobTitle(String? jobNumber, String? jobId) {
|
||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||
return 'Job $jobNumber';
|
||||
return 'Auftrag $jobNumber';
|
||||
}
|
||||
if (jobId != null && jobId.length >= 6) {
|
||||
return 'Job ${jobId.substring(0, 6).toUpperCase()}';
|
||||
return 'Auftrag ${jobId.substring(0, 6).toUpperCase()}';
|
||||
}
|
||||
return 'Job-Chat';
|
||||
return 'Auftrags-Chat';
|
||||
}
|
||||
|
||||
String? _determineCounterpart(
|
||||
|
||||
@@ -21,9 +21,9 @@ class NotificationService {
|
||||
'Benachrichtigungen bei neuen Chat-Nachrichten';
|
||||
|
||||
static const String _jobChannelId = 'new_jobs';
|
||||
static const String _jobChannelName = 'Neue Jobs';
|
||||
static const String _jobChannelName = 'Neue Aufträge';
|
||||
static const String _jobChannelDescription =
|
||||
'Benachrichtigungen bei neuen Job-Zuweisungen';
|
||||
'Benachrichtigungen bei neuen Auftragszuweisungen';
|
||||
|
||||
int _nextId = 0;
|
||||
|
||||
@@ -49,12 +49,15 @@ class NotificationService {
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
_initialized = true;
|
||||
developer.log('NotificationService initialized',
|
||||
name: 'NotificationService');
|
||||
developer.log(
|
||||
'NotificationService initialized',
|
||||
name: 'NotificationService',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChatNotification({
|
||||
@@ -87,8 +90,13 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(_nextId++, title, body, details,
|
||||
payload: 'chat:$conversationKey');
|
||||
await _plugin.show(
|
||||
_nextId++,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: 'chat:$conversationKey',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showJobNotification({
|
||||
|
||||
@@ -440,11 +440,11 @@ class WebSocketService {
|
||||
if (currentJobCount > 0 && jobs.length > currentJobCount) {
|
||||
final newCount = jobs.length - currentJobCount;
|
||||
NotificationService().showJobNotification(
|
||||
title: 'Neue Jobs',
|
||||
title: 'Neue Aufträge',
|
||||
body:
|
||||
newCount == 1
|
||||
? 'Sie haben einen neuen Job erhalten.'
|
||||
: 'Sie haben $newCount neue Jobs erhalten.',
|
||||
? 'Sie haben einen neuen Auftrag erhalten.'
|
||||
: 'Sie haben $newCount neue Aufträge erhalten.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -597,11 +597,11 @@ class WebSocketService {
|
||||
|
||||
// Show notification with sound for new job
|
||||
NotificationService().showJobNotification(
|
||||
title: 'Neuer Job',
|
||||
title: 'Neuer Auftrag',
|
||||
body:
|
||||
jobNumber != null && jobNumber.isNotEmpty
|
||||
? 'Job $jobNumber wurde Ihnen zugewiesen.'
|
||||
: 'Sie haben einen neuen Job erhalten.',
|
||||
? 'Auftrag $jobNumber wurde Ihnen zugewiesen.'
|
||||
: 'Sie haben einen neuen Auftrag erhalten.',
|
||||
);
|
||||
|
||||
// Publish event via DartMQ for UI to handle
|
||||
@@ -642,8 +642,8 @@ class WebSocketService {
|
||||
final jobNumber = message.jobNumber ?? '';
|
||||
notificationTitle =
|
||||
jobNumber.isNotEmpty
|
||||
? 'Nachricht zu Job $jobNumber'
|
||||
: 'Neue Job-Nachricht';
|
||||
? 'Nachricht zu Auftrag $jobNumber'
|
||||
: 'Neue Auftragsnachricht';
|
||||
} else {
|
||||
notificationTitle = 'Neue Nachricht';
|
||||
}
|
||||
@@ -1216,6 +1216,7 @@ class WebSocketService {
|
||||
name: 'WebSocketService',
|
||||
);
|
||||
developer.log('Stack: $st', name: 'WebSocketService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-20
@@ -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
@@ -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.24+924
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.9.22</revision>
|
||||
<revision>0.9.24</revision>
|
||||
<java.version>21</java.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
|
||||
@@ -163,13 +163,13 @@ public class AiStatisticsService {
|
||||
- Untersuche, erwähne oder vermute niemals Daten anderer Benutzer
|
||||
|
||||
WICHTIG - ANTWORTSTIL:
|
||||
- Der Benutzer fragt nach einer Liste von Jobs/Aufträgen
|
||||
- Der Benutzer fragt nach einer Liste von Aufträgen
|
||||
- Gib ausschließlich ein JSON-Objekt im geforderten Format aus
|
||||
- Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus
|
||||
- Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen
|
||||
- Vermeide Formulierungen wie "Sie fragen", "Du fragst", "Die Frage ist", "Gewünscht ist" oder "Der Benutzer möchte wissen"
|
||||
- Im Feld "answer" beginnt die Antwort direkt mit dem Ergebnis
|
||||
- Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Jobnummern
|
||||
- Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Auftragsnummern
|
||||
- Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage
|
||||
- Maximal 1-2 Sätze
|
||||
- Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen
|
||||
@@ -803,27 +803,27 @@ public class AiStatisticsService {
|
||||
List<Job> jobs;
|
||||
if (customerFilter != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers für %s mit Status %s:**\n\n",
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers für %s mit Status %s:**\n\n",
|
||||
customerFilter, statusFilter.getDisplayName()));
|
||||
} else if (customerFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers für %s:**\n\n", customerFilter));
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers für %s:**\n\n", customerFilter));
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers mit Status %s:**\n\n",
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers mit Status %s:**\n\n",
|
||||
statusFilter.getDisplayName()));
|
||||
} else {
|
||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
||||
context.append("**Aktuelle Jobs des angemeldeten Benutzers:**\n\n");
|
||||
context.append("**Aktuelle Aufträge des angemeldeten Benutzers:**\n\n");
|
||||
}
|
||||
|
||||
context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
|
||||
context.append(String.format("Gefunden: %d Aufträge\n\n", jobs.size()));
|
||||
|
||||
// Show job summaries (max 10)
|
||||
int shown = 0;
|
||||
for (Job job : jobs) {
|
||||
if (shown >= 10) {
|
||||
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
|
||||
context.append(String.format("... und %d weitere Aufträge\n", jobs.size() - 10));
|
||||
break;
|
||||
}
|
||||
context.append(
|
||||
@@ -943,7 +943,7 @@ public class AiStatisticsService {
|
||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||
job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt"))
|
||||
.toList();
|
||||
return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows);
|
||||
return buildHtmlTable(List.of("Auftragsnummer", "Kunde", "Status"), rows);
|
||||
}
|
||||
|
||||
private String buildStatusTableHtml(String currentUserId, String customerFilter) {
|
||||
@@ -1209,17 +1209,17 @@ public class AiStatisticsService {
|
||||
List<Job> jobs;
|
||||
if (customer != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter);
|
||||
yield String.format("%d Jobs für %s mit Status %s.", jobs.size(), customer,
|
||||
yield String.format("%d Aufträge für %s mit Status %s.", jobs.size(), customer,
|
||||
statusFilter.getDisplayName());
|
||||
} else if (customer != null) {
|
||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer);
|
||||
yield String.format("%d Jobs für %s.", jobs.size(), customer);
|
||||
yield String.format("%d Aufträge für %s.", jobs.size(), customer);
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
||||
yield String.format("%d Jobs mit Status %s.", jobs.size(), statusFilter.getDisplayName());
|
||||
yield String.format("%d Aufträge mit Status %s.", jobs.size(), statusFilter.getDisplayName());
|
||||
} else {
|
||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
||||
yield String.format("%d aktuelle Jobs.", jobs.size());
|
||||
yield String.format("%d aktuelle Aufträge.", jobs.size());
|
||||
}
|
||||
}
|
||||
case "status" -> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||
@@ -31,10 +32,16 @@ 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.FindAndModifyOptions;
|
||||
import org.springframework.data.mongodb.core.query.Criteria;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
@@ -53,6 +60,7 @@ public class MessageController {
|
||||
private final AppUserService appUserService;
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
private final CargoItemRepository cargoItemRepository;
|
||||
|
||||
@@ -72,11 +80,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;
|
||||
@@ -178,30 +187,34 @@ public class MessageController {
|
||||
* go to generic processing.
|
||||
*/
|
||||
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();
|
||||
|
||||
switch (key) {
|
||||
case "PHOTO" -> processPhotoTaskCompletion(payload);
|
||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload);
|
||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload);
|
||||
case "TODOLIST" -> processTodoListTaskCompletion(payload);
|
||||
case "BARCODE" -> processBarcodeTaskCompletion(payload);
|
||||
case "COMMENT" -> processCommentTaskCompletion(payload);
|
||||
case "PHOTO" -> processPhotoTaskCompletion(payload, appUserId);
|
||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload, appUserId);
|
||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload, appUserId);
|
||||
case "TODOLIST" -> processTodoListTaskCompletion(payload, appUserId);
|
||||
case "BARCODE" -> processBarcodeTaskCompletion(payload, appUserId);
|
||||
case "COMMENT" -> processCommentTaskCompletion(payload, appUserId);
|
||||
default -> log.error("[TASK] Unknown taskType: {}", taskType);
|
||||
}
|
||||
}
|
||||
|
||||
private void processConfirmationTaskCompletion(Map<String, Object> payload) {
|
||||
private void processConfirmationTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Bestätigung durchgeführt");
|
||||
completeTaskWithHistory(taskId, "Bestätigung durchgeführt", appUserId);
|
||||
}
|
||||
|
||||
private void processTodoListTaskCompletion(Map<String, Object> payload) {
|
||||
private void processTodoListTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt");
|
||||
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt", appUserId);
|
||||
}
|
||||
|
||||
private void processBarcodeTaskCompletion(Map<String, Object> payload) {
|
||||
private void processBarcodeTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -220,7 +233,7 @@ public class MessageController {
|
||||
|
||||
if (!barcodes.isEmpty()) {
|
||||
for (String barcodeString : barcodes) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
|
||||
completedBy);
|
||||
barcodeRepository.save(barcodeEntry);
|
||||
@@ -238,13 +251,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Barcode completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processSignatureTaskCompletion(Map<String, Object> payload) {
|
||||
private void processSignatureTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -261,7 +274,7 @@ public class MessageController {
|
||||
String signatureNote = signatureNoteObj instanceof String s ? s : null;
|
||||
if (signatureSvgObj instanceof String signatureSvg) {
|
||||
if (!signatureSvg.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
||||
signatureNote, completedBy);
|
||||
signatureRepository.save(signatureEntry);
|
||||
@@ -279,13 +292,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Signature completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processPhotoTaskCompletion(Map<String, Object> payload) {
|
||||
private void processPhotoTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -304,7 +317,7 @@ public class MessageController {
|
||||
|
||||
if (!photos.isEmpty()) {
|
||||
for (String photoString : photos) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||
photoRepository.save(photoEntry);
|
||||
}
|
||||
@@ -319,13 +332,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Photo completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processCommentTaskCompletion(Map<String, Object> payload) {
|
||||
private void processCommentTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -339,7 +352,7 @@ public class MessageController {
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object commentTextObj = extraData.get("commentText");
|
||||
if (commentTextObj instanceof String commentText && !commentText.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
|
||||
commentRepository.save(commentEntry);
|
||||
extraDataSummary = "Kommentar: " + commentText;
|
||||
@@ -350,13 +363,13 @@ public class MessageController {
|
||||
extraDataSummary = "Kommentar abgegeben";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Comment completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTaskWithHistory(Object tid, String extraDataSummary) {
|
||||
private void completeTaskWithHistory(Object tid, String extraDataSummary, String appUserId) {
|
||||
String taskIdStr = tid.toString();
|
||||
try {
|
||||
ObjectId taskId = new ObjectId(taskIdStr);
|
||||
@@ -365,6 +378,7 @@ public class MessageController {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
task.setCompletedBy(resolveTaskActor(task, appUserId));
|
||||
task.setCompleted(true);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
@@ -379,9 +393,9 @@ public class MessageController {
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType;
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
||||
extraDataSummary);
|
||||
extraDataSummary, task.getDescription() != null ? task.getDescription() : "");
|
||||
} catch (Exception e) {
|
||||
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
|
||||
e.getMessage(), e);
|
||||
@@ -390,7 +404,7 @@ public class MessageController {
|
||||
// Send email notification for task completion
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
||||
// Job completion is no longer auto-triggered by task completion.
|
||||
// It is now driven by explicit station_completed messages from the app
|
||||
@@ -406,55 +420,26 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
|
||||
try {
|
||||
var allTasks = taskAssignmentService.findTasksForJob(jobId);
|
||||
if (allTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mandatoryTasks = allTasks.stream().filter(task -> !task.isOptional()).toList();
|
||||
if (mandatoryTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
|
||||
|
||||
if (allCompleted) {
|
||||
updateJobStatusToCompleted(jobId);
|
||||
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);
|
||||
private String resolveTaskActor(BaseTask task, String appUserId) {
|
||||
if (appUserId != null && !appUserId.isBlank()) {
|
||||
return appUserId;
|
||||
}
|
||||
return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
}
|
||||
|
||||
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").nin(JobStatus.COMPLETED, JobStatus.CANCELLED));
|
||||
Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
|
||||
boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
|
||||
if (completed) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +454,9 @@ public class MessageController {
|
||||
* "hasIncompleteOptionalTasks": false
|
||||
* }
|
||||
*
|
||||
* The job is marked as completed once this message is received and all
|
||||
* mandatory tasks across all stations are completed.
|
||||
* Intermediate stations send a station notification. The final route station
|
||||
* completes the job once all mandatory tasks are done, sending only the job
|
||||
* notification. Confirmations are recorded atomically to suppress duplicates.
|
||||
*/
|
||||
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
||||
try {
|
||||
@@ -490,19 +476,76 @@ public class MessageController {
|
||||
return;
|
||||
}
|
||||
|
||||
ObjectId jobId = jobOpt.get().getId();
|
||||
Job job = jobOpt.get();
|
||||
ObjectId jobId = job.getId();
|
||||
int stationOrder = Integer.parseInt(String.valueOf(payload.get("stationOrder")));
|
||||
List<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";
|
||||
|
||||
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber,
|
||||
payload.get("stationOrder"));
|
||||
stationOrder);
|
||||
|
||||
checkAndHandleJobCompletion(jobId, completedBy);
|
||||
Query query = Query.query(Criteria.where("_id").is(jobId)
|
||||
.and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED)
|
||||
.and("completedStationOrders").ne(stationOrder));
|
||||
Update update = new Update().addToSet("completedStationOrders", stationOrder)
|
||||
.set("updatedAt", LocalDateTime.now());
|
||||
Job confirmedJob = mongoTemplate.findAndModify(query, update,
|
||||
FindAndModifyOptions.options().returnNew(true), Job.class);
|
||||
boolean firstConfirmation = confirmedJob != null;
|
||||
if (confirmedJob == null) {
|
||||
// A replay may finish a previously interrupted job transition,
|
||||
// but must never send another station notification.
|
||||
confirmedJob = jobRepository.findById(jobId).orElse(null);
|
||||
if (confirmedJob == null || confirmedJob.getStatus() == JobStatus.COMPLETED
|
||||
|| confirmedJob.getStatus() == JobStatus.CANCELLED
|
||||
|| confirmedJob.getCompletedStationOrders() == null
|
||||
|| !confirmedJob.getCompletedStationOrders().contains(stationOrder)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int lastStationOrder = stations.stream().mapToInt(DeliveryStation::getStationOrder).max().orElse(0);
|
||||
boolean lastStationConfirmed = confirmedJob.getCompletedStationOrders().contains(lastStationOrder);
|
||||
boolean allMandatoryTasksCompleted = tasks.stream().filter(task -> !task.isOptional())
|
||||
.allMatch(BaseTask::isCompleted);
|
||||
|
||||
if (lastStationConfirmed && allMandatoryTasksCompleted) {
|
||||
if (updateJobStatusToCompleted(jobId)) {
|
||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||
}
|
||||
} else if (firstConfirmation && stationOrder != lastStationOrder) {
|
||||
emailService.sendStationCompletionNotification(jobId, stationOrder, completedBy);
|
||||
}
|
||||
jobUpdateBroadcaster.broadcast(jobId);
|
||||
} catch (Exception e) {
|
||||
log.error("[STATION] Error handling station_completed: {}", e.getMessage());
|
||||
log.error("[STATION] Error handling station_completed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean belongsToStation(BaseTask task, DeliveryStation station, int stationOrder) {
|
||||
if (station != null && station.getStationId() != null && task.getStationId() != null) {
|
||||
return station.getStationId().equals(task.getStationId());
|
||||
}
|
||||
return (task.getStationOrder() != null ? task.getStationOrder() : 0) == stationOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client via WebSocket. Client sends to
|
||||
* /server/message with payload: { "content": "message payload", "contentType":
|
||||
|
||||
@@ -66,12 +66,12 @@ public class MessagingConfig {
|
||||
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null;
|
||||
messageController.handleTaskCompleted(payloadMap, taskType);
|
||||
messageController.handleTaskCompleted(payloadMap, taskType, appUserId);
|
||||
});
|
||||
});
|
||||
|
||||
// Station completion handler — marks a job as completed once all mandatory
|
||||
// tasks have been finished and the app confirms the station is done.
|
||||
// Station completion handler — notifies for intermediate stations and completes
|
||||
// the job after the final station and all mandatory tasks are done.
|
||||
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
messageController.handleStationCompleted(appUserId, payloadMap);
|
||||
|
||||
@@ -168,6 +168,9 @@ public class Job {
|
||||
@Field("delivery_stations")
|
||||
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
|
||||
* job id is returned as a string when jobs are retrieved via API.
|
||||
|
||||
@@ -49,6 +49,10 @@ public class JobHistory {
|
||||
*/
|
||||
private String details;
|
||||
|
||||
/** Task reference and description as recorded when the event occurred. */
|
||||
private ObjectId taskId;
|
||||
private String taskDescription;
|
||||
|
||||
/**
|
||||
* Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
|
||||
*/
|
||||
@@ -96,4 +100,4 @@ public class JobHistory {
|
||||
public String getJobIdAsString() {
|
||||
return jobId != null ? jobId.toHexString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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());
|
||||
|
||||
+1
-1
@@ -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()));
|
||||
|
||||
@@ -172,7 +172,7 @@ public class AddJobService {
|
||||
* Generiert eine eindeutige Auftragsnummer
|
||||
*/
|
||||
private String generateJobNumber() {
|
||||
String prefix = "JOB";
|
||||
String prefix = "AUFTRAG";
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
|
||||
// Zähle Aufträge des aktuellen Tages
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -209,6 +209,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
cardContent.add(description);
|
||||
}
|
||||
|
||||
String taskDescription = jobHistoryService.getTaskDescription(entry);
|
||||
if (!taskDescription.isBlank()) {
|
||||
Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + taskDescription);
|
||||
instructions.addClassName("timeline-description");
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
cardContent.add(instructions);
|
||||
}
|
||||
|
||||
// Photo preview for photo tasks
|
||||
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null
|
||||
&& entry.getDetails().contains("Task-Typ: PHOTO")) {
|
||||
@@ -239,9 +247,10 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
}
|
||||
|
||||
// Changed by (if available)
|
||||
if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) {
|
||||
Span changedBy = new Span(getTranslation("jobhistory.changedby", entry.getChangedBy()));
|
||||
// Author of this history entry.
|
||||
String createdBy = jobHistoryService.getCreatedByName(entry);
|
||||
if (createdBy != null && !createdBy.isBlank()) {
|
||||
Span changedBy = new Span(getTranslation("jobhistory.changedby", createdBy));
|
||||
changedBy.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-xs)").set("margin-top", "var(--lumo-space-xs)")
|
||||
.set("display", "block");
|
||||
@@ -260,7 +269,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||
return getTranslation("jobhistory.entry.create.reason");
|
||||
}
|
||||
return entry.getReason() != null ? entry.getReason() : "";
|
||||
return updateOrderTerminology(entry.getReason());
|
||||
}
|
||||
|
||||
private String getLocalizedDescription(JobHistory entry) {
|
||||
@@ -270,7 +279,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription()));
|
||||
}
|
||||
return entry.getDescription();
|
||||
return entry.getChangeType() == JobHistoryType.UPDATE || entry.getChangeType() == JobHistoryType.ASSIGNMENT
|
||||
? updateOrderTerminology(entry.getDescription()) : entry.getDescription();
|
||||
}
|
||||
|
||||
private String updateOrderTerminology(String text) {
|
||||
return text == null ? "" : text.replace("Job erstellt", "Auftrag erstellt")
|
||||
.replace("Job aktualisiert", "Auftrag aktualisiert").replace("Job-Daten", "Auftragsdaten")
|
||||
.replace("Job zugewiesen", "Auftrag zugewiesen").replace("Job-Zuweisung", "Auftragszuweisung");
|
||||
}
|
||||
|
||||
private String extractDescriptionValue(String description) {
|
||||
|
||||
@@ -555,6 +555,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
|
||||
private String summarizeDeliveryTask(BaseTask task) {
|
||||
String summary = summarizeDeliveryTaskType(task);
|
||||
String description = trimToNull(task.getDescription());
|
||||
return description != null && !summary.contains(description) ? summary + " – " + description : summary;
|
||||
}
|
||||
|
||||
private String summarizeDeliveryTaskType(BaseTask task) {
|
||||
if (task instanceof ConfirmationTask confirmationTask) {
|
||||
String buttonText = trimToNull(confirmationTask.getButtonText());
|
||||
if (buttonText != null) {
|
||||
@@ -1140,6 +1146,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
dialogContent.add(statusSpan);
|
||||
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + task.getDescription());
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
dialogContent.add(instructions);
|
||||
}
|
||||
|
||||
// Task-specific details
|
||||
addTaskSpecificDetails(dialogContent, task);
|
||||
|
||||
@@ -1150,7 +1162,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt())));
|
||||
}
|
||||
if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) {
|
||||
dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy()));
|
||||
dialogContent.add(new Span("Abgeschlossen von: "
|
||||
+ jobHistoryService.resolveUserName(task.getCompletedBy())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,6 +1387,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
taskDescription.addClassName("job-task-description");
|
||||
|
||||
taskContent.add(headerRow, taskDescription);
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
Span instructions = new Span(task.getDescription());
|
||||
instructions.addClassName("job-task-description");
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
taskContent.add(instructions);
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
Div statusIndicator = new Div();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
@@ -20,6 +21,8 @@ import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -77,7 +80,7 @@ public class EmailService {
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
String taskTypeName = getTaskTypeDisplayName(taskType);
|
||||
@@ -85,7 +88,7 @@ public class EmailService {
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||
}
|
||||
@@ -195,6 +198,69 @@ public class EmailService {
|
||||
};
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendStationCompletionNotification(ObjectId jobId, int stationOrder, String completedBy) {
|
||||
try {
|
||||
Optional<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
|
||||
public void sendJobCompletionNotification(ObjectId jobId, String completedBy) {
|
||||
try {
|
||||
@@ -236,7 +302,7 @@ public class EmailService {
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Auftrag abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
|
||||
@@ -246,8 +312,8 @@ public class EmailService {
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("alle Aufgaben für den folgenden Auftrag wurden erfolgreich abgeschlossen:\n\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||
}
|
||||
@@ -270,7 +336,7 @@ public class EmailService {
|
||||
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
|
||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||
|
||||
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
||||
body.append("Der Auftrag ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
||||
body.append("Mit freundlichen Grüßen,\n");
|
||||
body.append("Ihr Votianlt-System");
|
||||
|
||||
@@ -325,7 +391,7 @@ public class EmailService {
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Neuer Job erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Neuer Auftrag erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
|
||||
@@ -335,8 +401,8 @@ public class EmailService {
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("ein neuer Auftrag wurde erfolgreich erstellt:\n\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
|
||||
if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
||||
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
||||
@@ -374,7 +440,7 @@ public class EmailService {
|
||||
: "Unbekannt")
|
||||
.append("\n\n");
|
||||
|
||||
body.append("Der Job ist nun im System verfügbar und kann bearbeitet werden.\n\n");
|
||||
body.append("Der Auftrag ist nun im System verfügbar und kann bearbeitet werden.\n\n");
|
||||
body.append("Mit freundlichen Grüßen,\n");
|
||||
body.append("Ihr Votianlt-System");
|
||||
|
||||
|
||||
@@ -5,12 +5,20 @@ import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobHistory;
|
||||
import de.assecutor.votianlt.model.JobHistoryType;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.JobHistoryRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -18,10 +26,20 @@ public class JobHistoryService {
|
||||
|
||||
private final JobHistoryRepository jobHistoryRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TaskRepository taskRepository;
|
||||
private final JobRepository jobRepository;
|
||||
|
||||
public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper) {
|
||||
public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper,
|
||||
AppUserRepository appUserRepository, UserRepository userRepository, TaskRepository taskRepository,
|
||||
JobRepository jobRepository) {
|
||||
this.jobHistoryRepository = jobHistoryRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
this.jobRepository = jobRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,9 +47,9 @@ public class JobHistoryService {
|
||||
*/
|
||||
public void logJobCreation(Job job, String createdBy) {
|
||||
try {
|
||||
JobHistory history = new JobHistory(job.getId(), "Job erstellt",
|
||||
"Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
||||
createdBy, JobHistoryType.CREATE, null, "Job erstellt");
|
||||
JobHistory history = new JobHistory(job.getId(), "Auftrag erstellt",
|
||||
"Neuer Auftrag wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
||||
createdBy, JobHistoryType.CREATE, null, "Auftrag erstellt");
|
||||
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
history.setDetails("Kunde: " + job.getDeliveryCompany());
|
||||
@@ -70,7 +88,7 @@ public class JobHistoryService {
|
||||
try {
|
||||
String description = generateUpdateDescription(oldJob, newJob);
|
||||
|
||||
JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Job aktualisiert",
|
||||
JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Auftrag aktualisiert",
|
||||
description, changedBy, JobHistoryType.UPDATE, serializeJobForComparison(oldJob),
|
||||
serializeJobForComparison(newJob));
|
||||
|
||||
@@ -93,6 +111,11 @@ public class JobHistoryService {
|
||||
*/
|
||||
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||
String taskDisplayName, String extraDataSummary) {
|
||||
logTaskCompletion(jobId, taskType, taskId, completedBy, taskDisplayName, extraDataSummary, null);
|
||||
}
|
||||
|
||||
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||
String taskDisplayName, String extraDataSummary, String taskDescription) {
|
||||
try {
|
||||
String taskName = taskDisplayName != null ? taskDisplayName : taskType;
|
||||
String description = String.format("Aufgabe abgeschlossen: %s", taskName);
|
||||
@@ -101,8 +124,10 @@ public class JobHistoryService {
|
||||
description += " - " + extraDataSummary;
|
||||
}
|
||||
|
||||
JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, completedBy,
|
||||
JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, resolveUserName(completedBy),
|
||||
JobHistoryType.TASK_COMPLETED, "In Bearbeitung", "Abgeschlossen");
|
||||
history.setTaskId(ObjectId.isValid(taskId) ? new ObjectId(taskId) : null);
|
||||
history.setTaskDescription(taskDescription);
|
||||
|
||||
// Detaillierte Informationen in details speichern
|
||||
StringBuilder details = new StringBuilder();
|
||||
@@ -132,11 +157,11 @@ public class JobHistoryService {
|
||||
try {
|
||||
String description;
|
||||
if (oldAssignee == null && newAssignee != null) {
|
||||
description = "Job zugewiesen an: " + newAssignee;
|
||||
description = "Auftrag zugewiesen an: " + newAssignee;
|
||||
} else if (oldAssignee != null && newAssignee == null) {
|
||||
description = "Job-Zuweisung entfernt von: " + oldAssignee;
|
||||
description = "Auftragszuweisung entfernt von: " + oldAssignee;
|
||||
} else {
|
||||
description = String.format("Job-Zuweisung geändert von %s zu %s", oldAssignee, newAssignee);
|
||||
description = String.format("Auftragszuweisung geändert von %s zu %s", oldAssignee, newAssignee);
|
||||
}
|
||||
|
||||
JobHistory history = new JobHistory(job.getId(), "Zuweisung geändert", description, changedBy,
|
||||
@@ -177,6 +202,85 @@ public class JobHistoryService {
|
||||
return jobHistoryRepository.countByJobId(jobId);
|
||||
}
|
||||
|
||||
public String getTaskDescription(JobHistory entry) {
|
||||
if (entry.getTaskDescription() != null) {
|
||||
return entry.getTaskDescription();
|
||||
}
|
||||
return findHistoryTask(entry).map(BaseTask::getDescription).orElse("");
|
||||
}
|
||||
|
||||
public String getCreatedByName(JobHistory entry) {
|
||||
if (!isUnknownUser(entry.getChangedBy())) {
|
||||
return resolveUserName(entry.getChangedBy());
|
||||
}
|
||||
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED) {
|
||||
Optional<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
|
||||
|
||||
private String formatStatus(JobStatus status) {
|
||||
@@ -197,7 +301,7 @@ public class JobHistoryService {
|
||||
}
|
||||
|
||||
private String generateUpdateDescription(Job oldJob, Job newJob) {
|
||||
StringBuilder description = new StringBuilder("Job-Daten aktualisiert");
|
||||
StringBuilder description = new StringBuilder("Auftragsdaten aktualisiert");
|
||||
|
||||
if (oldJob == null) {
|
||||
return description.toString();
|
||||
@@ -241,4 +345,4 @@ public class JobHistoryService {
|
||||
return "Serialization failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ page.title.statistics=Statistiken
|
||||
page.title.password.forget=Passwort zurücksetzen
|
||||
page.title.invoices=Rechnungen
|
||||
page.title.appusers=App-Nutzer
|
||||
page.title.job.history=Job Historie
|
||||
page.title.job.history=Auftrags-Historie
|
||||
page.title.message.history=Nachrichtenverlauf
|
||||
page.title.myinvoices=Rechnungen
|
||||
page.title.job.create=Neuen Auftrag anlegen
|
||||
@@ -583,11 +583,11 @@ addjob.validation.address.correct=Adresse korrigieren
|
||||
|
||||
# Job Summary
|
||||
jobsummary.title=Zusammenfassung
|
||||
jobsummary.error.noid=Keine Job-ID angegeben
|
||||
jobsummary.error.invalidid=Ungültige Job-ID Format: {0}
|
||||
jobsummary.error.notfound=Job mit ID {0} nicht gefunden
|
||||
jobsummary.error.noid=Keine Auftrags-ID angegeben
|
||||
jobsummary.error.invalidid=Ungültiges Format der Auftrags-ID: {0}
|
||||
jobsummary.error.notfound=Auftrag mit ID {0} nicht gefunden
|
||||
jobsummary.button.sendmessage=Nachricht senden
|
||||
jobsummary.button.jobhistory=Job Historie
|
||||
jobsummary.button.jobhistory=Auftrags-Historie
|
||||
jobsummary.button.complete=Auftrag manuell abschließen
|
||||
jobsummary.dialog.complete.title=Auftrag abschließen
|
||||
jobsummary.dialog.complete.text=Möchten Sie den Auftrag {0} manuell abschließen?
|
||||
@@ -669,8 +669,8 @@ jobs.notification.delete.error=Fehler beim Löschen: {0}
|
||||
|
||||
# Create Invoice
|
||||
createinvoice.title=Rechnung erstellen \u2013 Auftrag {0}
|
||||
createinvoice.error.invalidid=Ungültige Job-ID
|
||||
createinvoice.error.notfound=Job nicht gefunden
|
||||
createinvoice.error.invalidid=Ungültige Auftrags-ID
|
||||
createinvoice.error.notfound=Auftrag nicht gefunden
|
||||
createinvoice.button.create=Rechnung erstellen
|
||||
createinvoice.section.job=Auftragsdetails
|
||||
createinvoice.section.route=Streckeninfo
|
||||
@@ -986,7 +986,7 @@ csv.header.customer=Auftraggeber
|
||||
csv.header.jobnumber=Auftragsnummer
|
||||
csv.header.jobdate=Auftragsdatum
|
||||
csv.header.destination=Zielort
|
||||
csv.filename=jobs.csv
|
||||
csv.filename=auftraege.csv
|
||||
|
||||
# DatePicker I18n
|
||||
datepicker.month.januar=Januar
|
||||
@@ -1022,15 +1022,15 @@ jobhistory.status.pickedup=Abgeholt
|
||||
jobhistory.status.intransit=Unterwegs
|
||||
jobhistory.status.delivered=Zugestellt
|
||||
jobhistory.image.alt=Vergrößertes Foto
|
||||
jobhistory.title=Jobhistorie
|
||||
jobhistory.header=Jobhistorie für {0}
|
||||
jobhistory.title=Auftrags-Historie
|
||||
jobhistory.header=Auftrags-Historie für {0}
|
||||
jobhistory.info.customer=Kunde: {0}
|
||||
jobhistory.info.createdat=Erstellt am: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} Einträge in der Historie
|
||||
jobhistory.changedby=Geändert von: {0}
|
||||
jobhistory.entry.create.reason=Job erstellt
|
||||
jobhistory.entry.create.description=Neuer Job wurde erstellt: {0}
|
||||
jobhistory.changedby=Erstellt von: {0}
|
||||
jobhistory.entry.create.reason=Auftrag erstellt
|
||||
jobhistory.entry.create.description=Neuer Auftrag wurde erstellt: {0}
|
||||
|
||||
# Version
|
||||
version.label=Version
|
||||
|
||||
@@ -870,7 +870,7 @@ jobhistory.info.customer=Klient: {0}
|
||||
jobhistory.info.createdat=Loodud: {0}
|
||||
jobhistory.info.status=Olek: {0}
|
||||
jobhistory.count={0} kirjet ajaloos
|
||||
jobhistory.changedby=Muutnud: {0}
|
||||
jobhistory.changedby=Loonud: {0}
|
||||
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
|
||||
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
|
||||
version.label=Versioon
|
||||
|
||||
@@ -1028,7 +1028,7 @@ jobhistory.info.customer=Customer: {0}
|
||||
jobhistory.info.createdat=Created on: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} entries in history
|
||||
jobhistory.changedby=Changed by: {0}
|
||||
jobhistory.changedby=Created by: {0}
|
||||
jobhistory.entry.create.reason=Job Created
|
||||
jobhistory.entry.create.description=New job was created: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Cliente: {0}
|
||||
jobhistory.info.createdat=Creado el: {0}
|
||||
jobhistory.info.status=Estado: {0}
|
||||
jobhistory.count={0} entradas en el historial
|
||||
jobhistory.changedby=Modificado por: {0}
|
||||
jobhistory.changedby=Creado por: {0}
|
||||
jobhistory.entry.create.reason=Pedido creado
|
||||
jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Client : {0}
|
||||
jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0}
|
||||
jobhistory.info.status=Statut : {0}
|
||||
jobhistory.count={0} entr\u00e9es dans l'historique
|
||||
jobhistory.changedby=Modifi\u00e9 par : {0}
|
||||
jobhistory.changedby=Créé par : {0}
|
||||
jobhistory.entry.create.reason=Mission cr\u00e9\u00e9e
|
||||
jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0}
|
||||
|
||||
|
||||
@@ -961,7 +961,7 @@ jobhistory.info.customer=Klientas: {0}
|
||||
jobhistory.info.createdat=Sukurta: {0}
|
||||
jobhistory.info.status=Būsena: {0}
|
||||
jobhistory.count={0} įrašų istorijoje
|
||||
jobhistory.changedby=Pakeitė: {0}
|
||||
jobhistory.changedby=Sukūrė: {0}
|
||||
jobhistory.entry.create.reason=Užsakymas sukurtas
|
||||
jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klients: {0}
|
||||
jobhistory.info.createdat=Izveidots: {0}
|
||||
jobhistory.info.status=Statuss: {0}
|
||||
jobhistory.count={0} ieraksti vēsturē
|
||||
jobhistory.changedby=Mainīja: {0}
|
||||
jobhistory.changedby=Izveidoja: {0}
|
||||
jobhistory.entry.create.reason=Uzdevums izveidots
|
||||
jobhistory.entry.create.description=Izveidots jauns uzdevums: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klient: {0}
|
||||
jobhistory.info.createdat=Utworzono dnia: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} wpis\u00f3w w historii
|
||||
jobhistory.changedby=Zmienione przez: {0}
|
||||
jobhistory.changedby=Utworzone przez: {0}
|
||||
jobhistory.entry.create.reason=Zlecenie utworzone
|
||||
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Клиент: {0}
|
||||
jobhistory.info.createdat=Создано: {0}
|
||||
jobhistory.info.status=Статус: {0}
|
||||
jobhistory.count={0} записей в истории
|
||||
jobhistory.changedby=Изменено: {0}
|
||||
jobhistory.changedby=Создано: {0}
|
||||
jobhistory.entry.create.reason=Заказ создан
|
||||
jobhistory.entry.create.description=Создан новый заказ: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=M\u00fc\u015fteri: {0}
|
||||
jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0}
|
||||
jobhistory.info.status=Durum: {0}
|
||||
jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t
|
||||
jobhistory.changedby=De\u011fi\u015ftiren: {0}
|
||||
jobhistory.changedby=Oluşturan: {0}
|
||||
jobhistory.entry.create.reason=\u0130\u015f olu\u015fturuldu
|
||||
jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0}
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
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.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
|
||||
@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 Job job;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
job = new Job();
|
||||
job.setId(jobId);
|
||||
job.setJobNumber("AUFTRAG20260929001");
|
||||
job.setDeliveryStations(List.of(station(0), station(1)));
|
||||
when(jobRepository.findById(jobId)).thenReturn(Optional.of(job));
|
||||
}
|
||||
|
||||
@Test
|
||||
void intermediateStationSendsOnlyStationEmailEvenWhenAllTasksAreDone() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalStationSendsOnlyJobEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(1);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleStationWithUnfinishedOptionalTaskSendsOnlyJobEmail() {
|
||||
job.setDeliveryStations(List.of(station(0)));
|
||||
ConfirmationTask optional = task(0, false);
|
||||
optional.setOptional(true);
|
||||
tasks(task(0, true), optional);
|
||||
confirmOnce(0);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyJobWithoutEmbeddedStationsStillCompletes() {
|
||||
job.setDeliveryStations(null);
|
||||
tasks(task(0, true));
|
||||
confirmOnce(0);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedIntermediateStationSendsOnlyOneEmail() {
|
||||
tasks(task(0, true), task(1, false));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedFinalStationSendsOnlyOneJobEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||
.thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null));
|
||||
|
||||
complete(1);
|
||||
complete(1);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayCanFinishInterruptedJobCompletionWithoutStationEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||
.thenReturn(UpdateResult.acknowledged(0, 0L, null), UpdateResult.acknowledged(1, 1L, null));
|
||||
|
||||
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);
|
||||
verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteMandatoryTaskPreventsStationCompletion() {
|
||||
tasks(task(0, false), task(1, true));
|
||||
|
||||
complete(0);
|
||||
|
||||
verifyNoInteractions(mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationIdTakesPrecedenceOverStaleTaskOrder() {
|
||||
ConfirmationTask openTask = task(1, false);
|
||||
openTask.setStationId(job.getDeliveryStations().getFirst().getStationId());
|
||||
tasks(openTask);
|
||||
|
||||
complete(0);
|
||||
|
||||
verifyNoInteractions(mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownStationIsIgnored() {
|
||||
complete(7);
|
||||
|
||||
verifyNoInteractions(taskAssignmentService, mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalStationIsDeterminedByRouteOrderForJobsWithoutTasks() {
|
||||
job.setDeliveryStations(List.of(station(7), station(2)));
|
||||
tasks();
|
||||
confirmOnce(7);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(7);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void intermediateStationWithoutTasksStillSendsStationEmail() {
|
||||
tasks();
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalConfirmationWaitsForEarlierMandatoryWorkThenSendsOnlyJobEmail() {
|
||||
ConfirmationTask earlierTask = task(0, false);
|
||||
tasks(earlierTask, task(1, true));
|
||||
confirmOnce(1);
|
||||
|
||||
complete(1);
|
||||
verifyNoInteractions(emailService);
|
||||
earlierTask.setCompleted(true);
|
||||
job.setCompletedStationOrders(List.of(0, 1));
|
||||
when(mongoTemplate.findAndModify(any(Query.class), any(Update.class),
|
||||
any(FindAndModifyOptions.class), eq(Job.class))).thenReturn(job);
|
||||
allowJobCompletion();
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmationIsPersistedAtomicallyAndExcludesClosedJobs() {
|
||||
tasks(task(0, true), task(1, false));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
ArgumentCaptor<Query> 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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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");
|
||||
}
|
||||
}
|
||||
+33
@@ -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.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
@@ -71,6 +73,7 @@ class EmailServiceTest {
|
||||
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
|
||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||
assertThat(body).contains("Auftrag: ").doesNotContain("Job:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,6 +96,86 @@ class EmailServiceTest {
|
||||
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
|
||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||
assertThat(mailCaptor.getValue().getSubject()).startsWith("Auftrag abgeschlossen - ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationEmailUsesOrderTerminology() {
|
||||
User webUser = createWebUser();
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
|
||||
emailService.sendJobCreationNotification(job.getId(), webUser.getId().toHexString());
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
assertThat(mailCaptor.getValue().getSubject()).startsWith("Neuer Auftrag erstellt - ");
|
||||
assertThat(mailCaptor.getValue().getText()).contains("ein neuer Auftrag", "Auftrag: ")
|
||||
.doesNotContain("neuer Job", "Job:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationEmailContainsStationAddressAndActualCompletingAppUser() {
|
||||
User webUser = createWebUser();
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
job.setJobNumber("AUFTRAG20260929001");
|
||||
DeliveryStation station = createStation();
|
||||
job.setDeliveryStations(List.of(station));
|
||||
AppUser completingUser = createAssignedAppUser();
|
||||
completingUser.setVorname("Erika");
|
||||
completingUser.setNachname("Musterfrau");
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
when(appUserRepository.findById(completingUser.getId())).thenReturn(Optional.of(completingUser));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, completingUser.getId().toHexString());
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
SimpleMailMessage mail = mailCaptor.getValue();
|
||||
assertThat(mail.getTo()).containsExactly("anna@example.com");
|
||||
assertThat(mail.getFrom()).isEqualTo("noreply@example.com");
|
||||
assertThat(mail.getSubject()).isEqualTo("Abgeschlossene Station - AUFTRAG20260929001 - Station 2");
|
||||
assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001",
|
||||
"Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg",
|
||||
"Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStationDoesNotSendStationEmail() {
|
||||
Job job = createJob(createWebUser(), createAssignedAppUser());
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1");
|
||||
|
||||
verifyNoInteractions(mailSender, userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRecipientAddressDoesNotSendStationEmail() {
|
||||
User webUser = createWebUser();
|
||||
webUser.setEmail(" ");
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
job.setDeliveryStations(List.of(createStation()));
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1");
|
||||
|
||||
verifyNoInteractions(mailSender);
|
||||
}
|
||||
|
||||
private DeliveryStation createStation() {
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setStationOrder(1);
|
||||
station.setCompany("Empfänger GmbH");
|
||||
station.setFirstName("Petra");
|
||||
station.setLastName("Beispiel");
|
||||
station.setStreet("Hafenstraße");
|
||||
station.setHouseNumber("12");
|
||||
station.setAddressAddition("Tor 3");
|
||||
station.setZip("20457");
|
||||
station.setCity("Hamburg");
|
||||
return station;
|
||||
}
|
||||
|
||||
private User createWebUser() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user