Compare commits

...
7 Commits
Author SHA1 Message Date
Sven 5e6044e4b1 fix: Stationsabschluss absichern und Kundenformulare anpassen
Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
2026-10-06 15:09:51 +02:00
Sven 8758c53e5b feat: Neutrale Rechnungsvorlage als Kundenstandard 2026-10-05 11:33:54 +02:00
Sven 8044d82f04 feat: Rechnungsvorlage und Statusmails für Version 0.9.25
- HHA-Systemrechnungsvorlage mit durchgehendem Rahmen und PDF-Vorschau ergänzen
- Zeichenreihenfolge im Editor und Schriftformatierung beim PDF-Export korrigieren
- Stationsbetreff vereinheitlichen und Tätigkeitsbeschreibung in Aufgabenmails aufnehmen
- Regressionstests, aktualisierte Frontend-Bundles und Backend-Version 0.9.25 aufnehmen
- Docker-Release-Build mit sauberem Maven-Build und Stacktrace ausführen
2026-10-03 09:12:51 +02:00
Sven b642f42954 feat: Auftragsdetails und Stationsabschluss für Version 0.9.24
Preisanzeige in der App entfernen und Auftragsbezeichnungen vereinheitlichen.

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

Regressionstests ergänzen und App sowie Backend auf Version 0.9.24 anheben.
2026-10-01 11:31:59 +02:00
Sven 7325c77739 feat: Frachtanzeige und Stationsabschluss für Version 0.9.23
Fracht mit Maßen und Gewicht anzeigen und Stationsabschlüsse gegen Wiederholung absichern. Backend-Abschluss atomar ausführen und mit Regressionstests abdecken. Optionale Aufgaben beim Kopieren erhalten und Kunden mit Adressen anzeigen. App und Backend auf Version 0.9.23 aktualisieren.
2026-09-28 11:30:36 +02:00
SvenandClaude Fable 5.1 d7c0173493 chore: Version 0.9.22 für App und Backend
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 10:09:06 +02:00
SvenandClaude Fable 5.1 53d4160f27 chore: Version 0.9.21, Android-Build-Tooling und Flutter-Abhängigkeiten aktualisiert
- Version auf 0.9.21 angehoben (App und Backend)
- Gradle 8.14.3, AGP 8.11.1, Kotlin 2.2.20; Flutter-Migrator-Flags in gradle.properties
- camera ^0.12.0, mobile_scanner ^7.0.0
- tsconfig ignoreDeprecations auf 5.0, prod.bundle neu gebaut
- upload.sh entfernt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:48:59 +02:00
76 changed files with 3441 additions and 684 deletions

No files matched your search

+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+2 -2
View File
@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+87 -14
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:votianlt_app/models/cargo_item.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/delivery_station.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'services/database_service.dart';
import 'task_view.dart';
import 'widgets/offline_banner.dart';
@@ -12,8 +14,10 @@ import 'widgets/offline_banner.dart';
@visibleForTesting
Color? deliveryStationCardBackgroundColor(
DeliveryStation station,
Map<String, bool> taskStatuses,
) {
Map<String, bool> taskStatuses, {
bool isClosed = false,
}) {
if (isClosed) return AppColors.successSoft;
if (station.tasks.isEmpty) {
return null;
}
@@ -36,6 +40,7 @@ class CargoItemsView extends StatefulWidget {
class _CargoItemsViewState extends State<CargoItemsView> {
final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {};
Set<int> _completedStations = const {};
@override
void initState() {
@@ -45,9 +50,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
widget.job,
);
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
@@ -81,8 +90,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,13 +130,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: Colors.green[600],
),
const SizedBox(width: 4),
Text(
Flexible(
child: Text(
widget.job.pickupCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
const SizedBox(width: 8),
Icon(
Icons.arrow_forward,
@@ -142,7 +152,8 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
Flexible(
child: Text(
widget.job.deliveryCitiesDisplay.isNotEmpty
? widget.job.deliveryCitiesDisplay
: widget.job.deliveryCity,
@@ -151,12 +162,37 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: Colors.grey[700],
),
),
),
],
),
],
),
),
),
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 +202,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
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 +224,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 +309,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(),
);
}
@@ -251,6 +319,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
final backgroundColor = deliveryStationCardBackgroundColor(
station,
_taskStatuses,
isClosed: JobProgress(
widget.job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations,
).isStationClosed(station.stationOrder),
);
final title =
station.displayName.isNotEmpty ? station.displayName : station.company;
+3 -3
View File
@@ -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,
+55 -43
View File
@@ -8,6 +8,7 @@ import 'services/dart_mq.dart';
import 'services/chat_service.dart';
import 'models/delivery_station.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart';
@@ -23,6 +24,13 @@ import 'services/database_service.dart';
import 'navigation_observer.dart';
import 'routing_view.dart';
@visibleForTesting
Color? jobCardBackgroundColor(JobProgress progress) {
if (progress.isCompleted) return AppColors.successSoft;
if (progress.hasProgress) return AppColors.warningSoft;
return null;
}
class JobsView extends StatefulWidget {
const JobsView({super.key});
@@ -63,6 +71,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
bool _isLoggingOut = false;
final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {};
Map<String, Set<int>> _completedStations = const {};
Map<String, bool> _jobSeen = const {};
Map<String, double> _jobSwipeOffsets = const {};
String? _openJobId;
@@ -145,7 +154,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);
}
@@ -479,9 +488,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
final completedStations = <String, Set<int>>{};
for (final job in _appState.assignedJobs) {
completedStations[job.id] = await _databaseService
.loadCompletedStationOrders(job);
}
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
@@ -950,25 +965,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
statusColor = AppColors.textMuted;
}
// Determine card background color based on task completion
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
final totalTasks = job.tasks.length;
int completedTasks = 0;
for (final t in job.tasks) {
final isCompleted = _taskStatuses[t.id] ?? t.completed;
if (isCompleted) completedTasks++;
}
// Check if all tasks are completed (job is done)
final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks;
Color? cardBg;
if (totalTasks == 0 || completedTasks == 0) {
cardBg = null; // unchanged (default)
} else if (completedTasks > 0 && completedTasks < totalTasks) {
cardBg = AppColors.warningSoft;
} else if (completedTasks == totalTasks) {
cardBg = AppColors.successSoft;
}
final completedTasks = progress.completedTaskCount;
final isJobCompleted = progress.isCompleted;
final cardBg = jobCardBackgroundColor(progress);
// Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
final pickupDisplayName =
@@ -1211,7 +1216,17 @@ class _JobsViewState extends State<JobsView> with RouteAware {
),
],
),
// Progress bar for tasks
if (isJobCompleted) ...[
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).jobCompleted,
style: const TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w600,
),
),
],
// Progress bar for tasks (optional tasks retain their actual state).
if (totalTasks > 0) ...[
const SizedBox(height: 8),
Text(
@@ -1235,6 +1250,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
minHeight: 8,
backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>(
isJobCompleted ||
completedTasks >= totalTasks
? AppColors.success
: (completedTasks > 0
@@ -1406,7 +1422,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
],
),
const SizedBox(height: 8),
// Dates and Price
// Creation date
Row(
children: [
Expanded(
@@ -1428,27 +1444,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,
),
),
),
],
],
),
],
@@ -1468,6 +1463,14 @@ class _JobsViewState extends State<JobsView> with RouteAware {
}
void _toggleTaskCompletion(Job job, int taskIndex) {
final progress = JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations[job.id] ?? const {},
);
if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) {
return;
}
// Create a new task with toggled completion status
final updatedTask = job.tasks[taskIndex].copyWith(
completed: !job.tasks[taskIndex].completed,
@@ -1509,6 +1512,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
id: job.id,
jobNumber: job.jobNumber,
status: job.status,
completedStationOrders: job.completedStationOrders,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
createdBy: job.createdBy,
@@ -1711,7 +1715,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
const SizedBox(height: 8),
...job.tasks.asMap().entries.map(
(entry) => GestureDetector(
onTap: () => _toggleTaskCompletion(job, entry.key),
onTap:
JobProgress(
job,
taskStatuses: _taskStatuses,
localCompletedStations:
_completedStations[job.id] ?? const {},
).isStationClosed(entry.value.stationOrder ?? 0)
? null
: () => _toggleTaskCompletion(job, entry.key),
child: Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
+18 -15
View File
@@ -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';
+7
View File
@@ -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);
}
+10 -1
View File
@@ -87,9 +87,18 @@ class CargoItem {
@override
int get hashCode => id.hashCode;
bool get hasDimensions =>
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
/// Get formatted dimensions string for display
String get formattedDimensions {
return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm';
final dimensions = [lengthCm, widthCm, heightCm].map((value) {
if (!value.isFinite || value <= 0) return '–';
return value == value.truncateToDouble()
? value.toStringAsFixed(0)
: value.toString();
});
return '${dimensions.join(' × ')} cm';
}
/// Get formatted weight string for display
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -52,7 +52,7 @@ class DeliveryStation {
final tasks =
(json['tasks'] as List? ?? const []).map((rawTask) {
final taskJson = Map<String, dynamic>.from(rawTask as Map);
taskJson['stationOrder'] ??= stationOrder;
taskJson['stationOrder'] = stationOrder;
return Task.fromJson(taskJson);
}).toList()
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
+11 -1
View File
@@ -6,6 +6,7 @@ class Job {
final String id; // Will store the timestamp as string
final String jobNumber;
final String status;
final List<int> completedStationOrders;
final DateTime createdAt;
final DateTime updatedAt;
final String createdBy;
@@ -61,6 +62,7 @@ class Job {
required this.id,
required this.jobNumber,
required this.status,
this.completedStationOrders = const [],
required this.createdAt,
required this.updatedAt,
required this.createdBy,
@@ -109,7 +111,7 @@ class Job {
this.assignedTo,
this.location,
this.additionalData,
}) : title = title ?? 'Job $jobNumber',
}) : title = title ?? 'Auftrag $jobNumber',
description =
description ?? 'Transport von $pickupCity nach $deliveryCity';
@@ -332,6 +334,12 @@ class Job {
id: idValue,
jobNumber: jobJson['jobNumber']?.toString() ?? '',
status: jobJson['status']?.toString() ?? 'UNKNOWN',
completedStationOrders:
(jobJson['completedStationOrders'] as List? ?? const [])
.map((value) => int.tryParse(value.toString()))
.whereType<int>()
.toSet()
.toList(),
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
createdBy: jobJson['createdBy']?.toString() ?? '',
@@ -391,6 +399,7 @@ class Job {
id: id,
jobNumber: t(jobNumber),
status: t(status),
completedStationOrders: List.of(completedStationOrders),
createdAt: createdAt,
updatedAt: updatedAt,
createdBy: t(createdBy),
@@ -446,6 +455,7 @@ class Job {
'id': id,
'jobNumber': jobNumber,
'status': status,
'completedStationOrders': completedStationOrders,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'createdBy': createdBy,
+52
View File
@@ -0,0 +1,52 @@
import 'job.dart';
import 'task.dart';
/// Combines server state with work confirmed locally while offline.
class JobProgress {
final Job job;
final Map<String, bool> taskStatuses;
final Set<int> completedStationOrders;
JobProgress(
this.job, {
this.taskStatuses = const {},
Set<int> localCompletedStations = const {},
}) : completedStationOrders = {
...job.completedStationOrders,
...localCompletedStations,
};
bool isTaskCompleted(Task task) =>
task.completed || taskStatuses[task.id] == true;
bool get isCancelled => job.status.toUpperCase() == 'CANCELLED';
bool get isCompleted {
if (isCancelled) return false;
if (const {'COMPLETED', 'DONE'}.contains(job.status.toUpperCase())) {
return true;
}
// Same rule as the backend: explicit final-station confirmation and all
// mandatory tasks completed. Open optional tasks do not prevent completion.
final lastStationOrder =
job.deliveryStations.isEmpty
? 0
: job.deliveryStations
.map((station) => station.stationOrder)
.reduce((a, b) => a > b ? a : b);
return completedStationOrders.contains(lastStationOrder) &&
job.tasks.where((task) => !task.optional).every(isTaskCompleted);
}
bool isStationClosed(int stationOrder) =>
isCancelled ||
isCompleted ||
completedStationOrders.contains(stationOrder);
bool canCompleteTask(Task task) =>
!isStationClosed(task.stationOrder ?? 0) && !isTaskCompleted(task);
int get completedTaskCount => job.tasks.where(isTaskCompleted).length;
bool get hasProgress =>
completedTaskCount > 0 || completedStationOrders.isNotEmpty;
}
+3 -3
View File
@@ -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(
+17
View File
@@ -368,6 +368,23 @@ class DatabaseService {
}
}
/// Merge server confirmations and locally persisted offline confirmations.
Future<Set<int>> loadCompletedStationOrders(Job job) async {
final orders =
job.deliveryStations.isEmpty
? {0}
: job.deliveryStations
.map((station) => station.stationOrder)
.toSet();
final completed = <int>{...job.completedStationOrders};
for (final order in orders) {
if (await loadKeyValue('station_completed:${job.id}:$order') == '1') {
completed.add(order);
}
}
return completed;
}
/// Load all task completion statuses
Future<Map<String, bool>> loadAllTaskStatuses() async {
try {
+15 -7
View File
@@ -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({
+9 -8
View File
@@ -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;
}
}
+104 -19
View File
@@ -6,9 +6,11 @@ import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'app_state.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart';
@@ -42,29 +44,86 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService();
late Job _job;
Set<int> _localCompletedStations = {};
StreamSubscription<void>? _jobsUpdatedSubscription;
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@override
void initState() {
super.initState();
_job = widget.job;
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
(_) => _loadTaskStatuses(),
);
_loadTaskStatuses();
}
@override
void didUpdateWidget(covariant TaskView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.job, widget.job) ||
oldWidget.stationOrder != widget.stationOrder) {
_job = widget.job;
_statusesLoaded = false;
_loadTaskStatuses();
}
}
@override
void dispose() {
_jobsUpdatedSubscription?.cancel();
super.dispose();
}
JobProgress get _progress => JobProgress(
_job,
taskStatuses: {for (final id in _completedTasks) id: true},
localCompletedStations: _localCompletedStations,
);
bool _canCompleteTask(Task task) =>
mounted &&
_statusesLoaded &&
!_isCompletingStation &&
!_stationCompleted &&
_progress.canCompleteTask(task);
List<Task> get _visibleTasks {
final stationOrder = widget.stationOrder;
if (stationOrder == null) {
return widget.job.tasks;
return _job.tasks;
}
return widget.job.tasks
return _job.tasks
.where((task) => task.stationOrder == stationOrder)
.toList();
}
String get _stationCompletionKey =>
'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
var latestJob = _job;
for (final savedJob in await _databaseService.loadJobs()) {
if (savedJob.id == _job.id &&
!savedJob.updatedAt.isBefore(latestJob.updatedAt)) {
latestJob = savedJob;
}
}
final statuses = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
latestJob,
);
if (!mounted) return;
setState(() {
_job = latestJob;
_localCompletedStations = completedStations;
_statusesLoaded = true;
_completedTasks.clear();
// 1) Add all completed from DB
for (final entry in statuses.entries) {
@@ -73,11 +132,12 @@ class _TaskViewState extends State<TaskView> {
}
}
// 2) Merge: also mark tasks completed if the job JSON already had them completed
for (final t in widget.job.tasks) {
for (final t in _job.tasks) {
if (t.completed) {
_completedTasks.add(t.id);
}
}
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
});
}
@@ -88,7 +148,7 @@ class _TaskViewState extends State<TaskView> {
title: Text(
widget.stationTitle?.isNotEmpty == true
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
: '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -148,9 +208,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;
@@ -175,7 +237,9 @@ class _TaskViewState extends State<TaskView> {
child: ElevatedButton.icon(
onPressed: enabled ? _onCompleteStationPressed : null,
icon: const Icon(Icons.flag),
label: const Text('Station abschließen'),
label: Text(
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
@@ -184,6 +248,7 @@ class _TaskViewState extends State<TaskView> {
}
void _onCompleteStationPressed() {
if (!_canCompleteStation) return;
if (_hasIncompleteOptionalTasks) {
showDialog(
context: context,
@@ -214,21 +279,31 @@ 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(
jobId: widget.job.id,
jobNumber: widget.job.jobNumber,
await StompService().sendStationCompleted(
jobId: _job.id,
jobNumber: _job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Station abgeschlossen')),
);
_stationCompleted = true;
_localCompletedStations.add(stationOrder);
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);
}
}
}
@@ -265,7 +340,7 @@ class _TaskViewState extends State<TaskView> {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final canBeCompletedNow =
!isCompleted && _arePreviousTasksCompleted(index);
_canCompleteTask(task) && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
@@ -405,6 +480,7 @@ class _TaskViewState extends State<TaskView> {
}
void _showTaskCompletionDialog(Task task, int taskIndex) {
if (!_canCompleteTask(task)) return;
switch (task) {
case ConfirmationTask():
_showConfirmationDialog(task, taskIndex);
@@ -518,10 +594,12 @@ class _TaskViewState extends State<TaskView> {
(context) => PhotoCaptureScreen(
task: task,
onPhotosCompleted: (List<Uint8List> photoData) async {
if (!_canCompleteTask(task)) return;
// Compress + encode photos for network send (limit payload)
final List<String> base64List = await _compressAndEncodePhotos(
photoData,
);
if (!_canCompleteTask(task)) return;
final bool truncated = base64List.length < photoData.length;
// Try to persist full-quality (encoded) photos to DB for offline/backup
@@ -623,6 +701,7 @@ class _TaskViewState extends State<TaskView> {
(context) => SignatureCaptureScreen(
task: task,
onSignatureCompleted: (String svg, String note) async {
if (!_canCompleteTask(task)) return;
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
@@ -634,6 +713,7 @@ class _TaskViewState extends State<TaskView> {
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
if (!_canCompleteTask(task)) return;
// Store SVG for later use in this TaskView session
setState(() {
_signatureSvgByTask[task.id] = svg;
@@ -666,6 +746,7 @@ class _TaskViewState extends State<TaskView> {
(context) => BarcodeCaptureScreen(
task: task,
onBarcodesCompleted: (List<String> barcodes) async {
if (!_canCompleteTask(task)) return;
try {
// Save barcodes to database for later use
await _databaseService.saveTaskBarcodes(task.id, barcodes);
@@ -734,6 +815,8 @@ class _TaskViewState extends State<TaskView> {
String? taskType,
Map<String, dynamic>? extraData,
}) {
final tasks = _visibleTasks.where((task) => task.id == taskId);
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() {
_completedTasks.add(taskId);
@@ -756,7 +839,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 +982,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;
@@ -974,7 +1059,7 @@ class _TaskViewState extends State<TaskView> {
}
}
String _getRemark() => widget.job.remark;
String _getRemark() => _job.remark;
String? _getTaskStationLabel(Task task) {
if (widget.stationOrder != null) {
@@ -985,7 +1070,7 @@ class _TaskViewState extends State<TaskView> {
return null;
}
for (final station in widget.job.deliveryStations) {
for (final station in _job.deliveryStations) {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
+24 -24
View File
@@ -117,34 +117,34 @@ packages:
dependency: "direct main"
description:
name: camera
sha256: dfa8fc5a1adaeb95e7a54d86a5bd56f4bb0e035515354c8ac6d262e35cec2ec8
sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f"
url: "https://pub.dev"
source: hosted
version: "0.10.6"
camera_android:
version: "0.12.0+2"
camera_android_camerax:
dependency: transitive
description:
name: camera_android
sha256: "4db8a27da163130d913ab4360297549ead1c7f9a6a88e71c44e5f4d10081a3d4"
name: camera_android_camerax
sha256: cf6c248ef3d3c4846e99e488de9487d95927437e083c396e5fed2c1ece7f93a7
url: "https://pub.dev"
source: hosted
version: "0.10.10+6"
version: "0.7.4+1"
camera_avfoundation:
dependency: transitive
description:
name: camera_avfoundation
sha256: "951ef122d01ebba68b7a54bfe294e8b25585635a90465c311b2f875ae72c412f"
sha256: "866e9cd8370f8055d005c0413937a52dc1d7a472687f0ee3ce02392955aababa"
url: "https://pub.dev"
source: hosted
version: "0.9.21+2"
version: "0.10.2"
camera_platform_interface:
dependency: transitive
description:
name: camera_platform_interface
sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2"
sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498"
url: "https://pub.dev"
source: hosted
version: "2.10.0"
version: "2.13.1"
camera_web:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -593,26 +593,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: transitive
description:
@@ -625,10 +625,10 @@ packages:
dependency: "direct main"
description:
name: mobile_scanner
sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760
sha256: ce3f059ebd6dbfab7292bba0e893e354b46730636820d3c9ef69005ce2d55bce
url: "https://pub.dev"
source: hosted
version: "5.2.3"
version: "7.4.0"
objectbox:
dependency: "direct main"
description:
@@ -894,10 +894,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.11"
timezone:
dependency: transitive
description:
@@ -1131,5 +1131,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.29.0"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+3 -3
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.9.20+1
version: 0.9.25+925
environment:
sdk: ^3.7.0
@@ -56,14 +56,14 @@ dependencies:
path_provider: ^2.1.5
# Camera for photo tasks (Android/iOS/Web)
camera: ^0.10.5+9
camera: ^0.12.0
# File selection (mobile uses file_picker; desktop uses file_selector)
file_picker: ^8.0.6
file_selector: ^1.0.3
# Barcode scanning for mobile platforms
mobile_scanner: ^5.0.0
mobile_scanner: ^7.0.0
# Signature drawing canvas for capturing signatures
signature: ^5.5.0
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
Map<String, dynamic> task(
String id,
int order, {
bool completed = false,
bool optional = false,
}) => {
'id': id,
'jobId': 'job-1',
'stationOrder': order,
'completed': completed,
'optional': optional,
'taskSpecificData': {'taskType': 'CONFIRMATION', 'buttonText': 'Bestätigen'},
};
Job exampleJob({
String status = 'IN_PROGRESS',
List<Object> completedStations = const [],
bool mandatoryDone = true,
}) => Job.fromJson({
'id': 'job-1',
'status': status,
'completedStationOrders': completedStations,
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
task('required', 0, completed: mandatoryDone),
task('optional', 0, optional: true),
],
},
{
'stationOrder': 2,
'tasks': [task('final', 2, completed: true)],
},
],
});
void main() {
test(
'closed intermediate station locks optional tasks but leaves the next station open',
() {
final job = exampleJob(completedStations: [0]);
final progress = JobProgress(job);
expect(progress.isCompleted, isFalse);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(progress.isStationClosed(0), isTrue);
expect(progress.isStationClosed(2), isFalse);
},
);
test(
'server completion is authoritative despite unfinished optional tasks',
() {
final job = exampleJob(status: 'COMPLETED');
final progress = JobProgress(job);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(job.tasks[1].completed, isFalse);
},
);
test(
'all mandatory tasks alone do not complete the job without station confirmation',
() {
expect(JobProgress(exampleJob()).isCompleted, isFalse);
},
);
test(
'final station confirmation completes the job even with optional tasks open',
() {
expect(
JobProgress(exampleJob(completedStations: [0, 2])).isCompleted,
isTrue,
);
},
);
test(
'offline final station confirmation uses locally completed mandatory tasks',
() {
final progress = JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {0, 2},
taskStatuses: {'required': true},
);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
},
);
test(
'final confirmation cannot bypass unfinished mandatory work at an earlier station',
() {
expect(
JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {2},
).isCompleted,
isFalse,
);
},
);
test(
'completed station confirmations survive normalization and JSON storage',
() {
final saved =
exampleJob(
completedStations: [0, '2', 2, 'invalid'],
).normalized().toJson();
final restored = Job.fromJson(saved);
expect(restored.completedStationOrders, [0, 2]);
expect(JobProgress(restored).isStationClosed(0), isTrue);
},
);
test(
'legacy job with no stations completes only after explicit station zero confirmation',
() {
final job = Job.fromJson({
'id': 'legacy',
'status': 'IN_PROGRESS',
'tasks': [task('optional', 0, optional: true)],
});
expect(JobProgress(job).isCompleted, isFalse);
expect(JobProgress(job, localCompletedStations: {0}).isCompleted, isTrue);
},
);
test('cancelled jobs remain locked and are not reported as completed', () {
final progress = JobProgress(
exampleJob(status: 'CANCELLED'),
localCompletedStations: {0, 2},
);
expect(progress.isCompleted, isFalse);
expect(progress.isStationClosed(2), isTrue);
});
test('embedded station order takes precedence over a stale task order', () {
final job = Job.fromJson({
'id': 'job-1',
'completedStationOrders': [0],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [task('optional', 8, optional: true)],
},
{'stationOrder': 1, 'tasks': []},
],
});
expect(job.tasks.single.stationOrder, 0);
expect(JobProgress(job).canCompleteTask(job.tasks.single), isFalse);
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/app_theme.dart';
import 'package:votianlt_app/cargo_items_view.dart';
import 'package:votianlt_app/jobs_view.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
import 'package:votianlt_app/task_view.dart';
Job jobWithOptionalTask({
bool closedStation = true,
String status = 'IN_PROGRESS',
}) => Job.fromJson({
'id': 'job-1',
'jobNumber': 'TEST-1',
'status': status,
'completedStationOrders': closedStation ? [0] : [],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
{
'id': 'optional',
'jobId': 'job-1',
'optional': true,
'completed': false,
'description': 'Optionale Bestätigung',
'taskOrder': 0,
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Jetzt bestätigen',
},
},
],
},
{'stationOrder': 1, 'tasks': []},
],
});
void main() {
for (final status in ['IN_PROGRESS', 'COMPLETED']) {
testWidgets(
'optional task cannot open a dialog after station closure ($status)',
(tester) async {
final job = jobWithOptionalTask(status: status);
await tester.pumpWidget(
MaterialApp(home: TaskView(job: job, stationOrder: 0)),
);
await tester.pump();
final task = find.text('Optionale Bestätigung');
expect(task, findsOneWidget);
final tile = tester.widget<InkWell>(
find.ancestor(of: task, matching: find.byType(InkWell)).first,
);
expect(tile.onTap, isNull);
await tester.tap(task);
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
testWidgets('optional task remains executable at an open station', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: TaskView(
job: jobWithOptionalTask(closedStation: false),
stationOrder: 0,
),
),
);
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Jetzt bestätigen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'an already open dialog cannot complete a task after the job closes',
(tester) async {
Widget view(Job job) => MaterialApp(
home: TaskView(
key: const ValueKey('task-view'),
job: job,
stationOrder: 0,
),
);
await tester.pumpWidget(view(jobWithOptionalTask(closedStation: false)));
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.pumpWidget(view(jobWithOptionalTask(status: 'COMPLETED')));
await tester.pump();
await tester.tap(find.text('Jetzt bestätigen'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
final taskCard = tester.widget<Card>(
find
.ancestor(
of: find.text('Optionale Bestätigung'),
matching: find.byType(Card),
)
.first,
);
expect(
taskCard.elevation,
0,
); // still open and locked, never marked completed
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
test('completed job card is green with optional tasks still open', () {
final job = jobWithOptionalTask(status: 'COMPLETED');
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.successSoft);
expect(job.tasks.single.completed, isFalse);
});
test('offline final station completion makes the job card green', () {
final job = jobWithOptionalTask();
expect(
jobCardBackgroundColor(JobProgress(job, localCompletedStations: {1})),
AppColors.successSoft,
);
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.warningSoft);
expect(
deliveryStationCardBackgroundColor(
job.deliveryStations.first,
{},
isClosed: true,
),
AppColors.successSoft,
);
});
}
+2 -1
View File
@@ -64,7 +64,8 @@ cd "${SCRIPT_DIR}"
echo "Verwende Release-Version ${VERSION}."
echo "Baue Production-JAR für Version ${VERSION} ..."
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests -Drevision="${VERSION}" package)
# Klassen aus vorherigen Maven-/IDE-Builds vor dem Vaadin-Klassenscan entfernen.
(cd "${BACKEND_DIR}" && ./mvnw -e -Pproduction -DskipTests -Drevision="${VERSION}" clean package)
JAR_FILE_REL="target/votianlt-${VERSION}.jar"
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.20</revision>
<revision>0.9.26</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
Binary file not shown.
Binary file not shown.
@@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || {
initialized: false
};
window.initProfileInvoiceGenerator = function() {
// A tab can still be hidden when Vaadin sends its selected-change response.
// Wait for its actual layout instead of relying on fixed delays and a server callback.
window.showProfileInvoiceTemplate = function(container, view, templateData, masterdata, vatRate) {
if (window.pendingProfileInvoiceLoad) {
window.pendingProfileInvoiceLoad.disconnect();
window.pendingProfileInvoiceLoad = null;
}
function loadWhenVisible() {
var rect = container.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
window.invoiceGeneratorViewProfile = view;
window.profileInvoiceServiceData = null;
window.profileInvoiceVatRate = vatRate;
window.masterdataValues = masterdata;
window.initProfileInvoiceGenerator(container);
window.loadProfileTemplate(templateData);
return true;
}
if (!loadWhenVisible()) {
var observer = new ResizeObserver(function() {
if (window.pendingProfileInvoiceLoad !== observer) return;
if (loadWhenVisible()) {
observer.disconnect();
if (window.pendingProfileInvoiceLoad === observer) window.pendingProfileInvoiceLoad = null;
}
});
window.pendingProfileInvoiceLoad = observer;
observer.observe(container);
}
};
window.initProfileInvoiceGenerator = function(container) {
var containerId = 'invoice-canvas-container-profile';
var container = document.getElementById(containerId);
container = container || document.getElementById(containerId);
if (!container) {
console.error('Canvas container not found:', containerId);
@@ -233,28 +264,9 @@ window.initProfileInvoiceGenerator = function() {
var img = new Image();
img.onload = function() {
imageCache[el.imageData] = img;
// Calculate dimensions to maintain aspect ratio
var imgAspect = img.width / img.height;
var boxAspect = w / h;
var drawW, drawH, drawX, drawY;
if (imgAspect > boxAspect) {
drawW = w;
drawH = w / imgAspect;
drawX = x;
drawY = y + (h - drawH) / 2;
} else {
drawW = h * imgAspect;
drawH = h;
drawX = x + (w - drawW) / 2;
drawY = y;
}
ctx.drawImage(img, drawX, drawY, drawW, drawH);
// Redraw selection if this element is selected
if (selectedElement && selectedElement.id === el.id) {
drawSelection(el);
}
// Redraw in element order: a letterhead loaded asynchronously must
// stay behind the invoice text, including after resize or removal.
draw();
};
img.src = el.imageData;
}
@@ -1322,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() {
} else if (el.variable.startsWith('masterdata.')) {
// Masterdata variables - use actual value from masterdata or placeholder
var value = masterdata[el.variable];
if (value && value.trim() !== '') {
if (typeof value === 'string') {
el.text = value;
} else {
el.text = '[' + el.variable.replace('masterdata.', '') + ']';
@@ -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,45 @@ 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) {
Object taskId = payload != null ? payload.get("taskId") : null;
if (taskId == null || !ObjectId.isValid(taskId.toString())) {
return;
}
Optional<BaseTask> task = taskRepository.findById(new ObjectId(taskId.toString()));
if (task.isEmpty() || taskAssignmentService.findJobForTask(task.get())
.filter(job -> canCompleteTask(task.get(), job)).isEmpty()) {
// Check before dispatch: photo/signature/barcode/comment handlers also
// persist attachments before completing the task itself.
return;
}
String key = taskType == null ? "" : taskType.trim().toUpperCase();
switch (key) {
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 +244,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 +262,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 +285,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 +303,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 +328,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 +343,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 +363,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 +374,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,23 +389,23 @@ public class MessageController {
return;
}
BaseTask task = opt.get();
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) {
return;
}
task.setCompletedBy(resolveTaskActor(task, appUserId));
task.setCompleted(true);
task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task);
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty()) {
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
return;
}
ObjectId jobId = jobOpt.get().getId();
// Log detailed task completion in job history
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,8 +414,9 @@ 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";
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
String completedBy = resolveTaskActor(task, appUserId);
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy,
task.getDescription());
// Job completion is no longer auto-triggered by task completion.
// It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted).
@@ -406,55 +431,39 @@ public class MessageController {
}
}
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
private boolean canCompleteTask(BaseTask task, Job job) {
if (task.isCompleted() || job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED) {
return false;
}
int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
if (task.getStationId() != null && job.getDeliveryStations() != null) {
stationOrder = job.getDeliveryStations().stream().filter(Objects::nonNull)
.filter(station -> task.getStationId().equals(station.getStationId()))
.mapToInt(DeliveryStation::getStationOrder).findFirst().orElse(stationOrder);
}
return job.getCompletedStationOrders() == null || !job.getCompletedStationOrders().contains(stationOrder);
}
private String resolveTaskActor(BaseTask task, String appUserId) {
if (appUserId != null && !appUserId.isBlank()) {
return appUserId;
}
return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
}
private boolean updateJobStatusToCompleted(ObjectId jobId) {
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 void 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 +478,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 +500,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.)
*/
@@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog {
if (copy != null) {
copy.setDescription(original.getDescription());
copy.setOptional(original.isOptional());
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
copy.setCompleted(original.isCompleted());
copy.setCompletedAt(original.getCompletedAt());
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
customerLabelMap.clear();
for (Customer c : customers) {
CustomerAddressLabelHelper.putName(customerLabelMap, c,
CustomerAddressLabelHelper.put(customerLabelMap, c,
translationHelper.getTranslation("addjob.customer.unnamed"));
}
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
@@ -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
@@ -52,15 +52,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
companyName = new TextField(getTranslation("profile.company"));
companyName.setWidthFull();
// Steuernummer (Pflichtfeld)
// Steuernummer (optional)
taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
taxNumber.setRequiredIndicatorVisible(true);
taxNumber.setWidthFull();
taxNumber.addBlurListener(e -> validateField(taxNumber));
// USt-IdNr. (Pflichtfeld)
// USt-IdNr. (optional)
ustId = new TextField(getTranslation("profile.billing.ustid"));
ustId.setRequiredIndicatorVisible(true);
ustId.setPlaceholder("DE123456789");
ustId.setWidthFull();
ustId.addBlurListener(e -> validateUstId());
@@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void configureBinder() {
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
binder.forField(ustId)
.withValidator(value -> value == null || value.isBlank()
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
@@ -265,15 +262,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
/**
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
* Prüft eine angegebene USt-IdNr. auf gültiges Format
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
*/
private void validateUstId() {
String value = ustId.getValue();
if (value == null || value.trim().isEmpty()) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
} else {
@@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
}
private boolean validateAllFields() {
validateField(taxNumber);
validateUstId();
validateField(firstName);
validateField(lastName);
@@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city);
validateEmail();
return !taxNumber.isInvalid() && !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
return !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
&& !zip.isInvalid() && !city.isInvalid();
}
@@ -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()));
@@ -33,6 +33,7 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
@@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
currentUser = currentUserOpt.get();
Optional<InvoiceTemplate> templateOpt = invoiceTemplateService
.getTemplateByUserId(currentUser.getId().toString());
if (templateOpt.isEmpty()) {
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END);
return;
}
String templateData = templateOpt.get().getTemplateData();
InvoiceTemplate template = invoiceTemplateService
.getCustomerTemplateByUserId(currentUser.getId().toString());
String templateData = template.getTemplateData();
if (templateData == null || templateData.isBlank()) {
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END);
@@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
BigDecimal totalAmount = netAmount.add(vatAmount);
// Build variable substitution map
Map<String, String> variables = new HashMap<>();
// Master data (from user profile)
variables.put("masterdata.company_name", safe(currentUser.getCompany()));
variables.put("masterdata.contact_name", safe(currentUser.getFirstname()) + " " + safe(currentUser.getName()));
variables.put("masterdata.street", safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber()));
variables.put("masterdata.city", safe(currentUser.getZip()) + " " + safe(currentUser.getCity()));
variables.put("masterdata.email", safe(currentUser.getEmail()));
variables.put("masterdata.phone", safe(currentUser.getPhone()));
Map<String, String> variables = CustomerInvoiceTemplateVariables.create(currentUser,
userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber);
// Customer data (from job - look up Customer entity for full address)
String customerSelection = currentJob.getCustomerSelection();
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H2;
@@ -34,7 +35,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private final Binder<Customer> binder = new Binder<>(Customer.class);
// Form fields - labels set in constructor via setLabel()
private final TextField titleField = new TextField();
private final ComboBox<String> titleField = new ComboBox<>();
private final TextField companyNameField = new TextField();
private final TextField taxNumberField = new TextField();
private final TextField ustIdField = new TextField();
@@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
// Set field labels via i18n
titleField.setLabel(getTranslation("addjob.address.salutation"));
titleField.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"),
getTranslation("addjob.salutation.other"));
titleField.setPlaceholder(getTranslation("addjob.address.salutation.placeholder"));
companyNameField.setLabel(getTranslation("profile.company"));
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.setRequiredIndicatorVisible(true);
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setRequiredIndicatorVisible(true);
ustIdField.setPlaceholder("DE123456789");
firstnameField.setLabel(getTranslation("profile.firstname"));
lastNameField.setLabel(getTranslation("profile.lastname"));
@@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private void setupBinder() {
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required"))
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required"))
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustIdField)
.withValidator(value -> value == null || value.isBlank()
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId);
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
@@ -44,6 +44,7 @@ import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService;
import com.vaadin.flow.component.grid.Grid;
@@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private Checkbox billingEnabled;
private NumberField vatRateField;
private VerticalLayout propertiesPanelProfile;
private final Div canvasContainer;
private final ServiceRepository serviceRepository;
private Grid<Service> servicesGrid;
@@ -414,7 +416,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
// Mitte: Canvas
Div canvasContainer = new Div();
canvasContainer = new Div();
canvasContainer.setId("invoice-canvas-container-profile");
canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%");
@@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Bestehende Rechnungsdaten laden (nur für die Checkbox)
loadInvoiceData();
// Initialize invoice generator when the billing tab is selected
// Also register this view instance for JavaScript callbacks
// Load directly; the client waits until the billing canvas is visible.
tabSheet.addSelectedChangeListener(e -> {
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
+ " console.log('Canvas initialized, now loading template...'); "
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
+ " } else { " + " console.error('initProfileInvoiceGenerator not found'); " + " } "
+ "}, 300);", getElement());
loadInvoiceTemplate();
}
});
@@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue());
try {
String valuesJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData,
prefixField.getValue() + "12345"));
getElement().executeJs("if (window.updateProfileMasterdataValue) { "
+ "Object.entries(JSON.parse($0)).forEach(([key, value]) => "
+ "window.updateProfileMasterdataValue(key, value)); }", valuesJson);
} catch (com.fasterxml.jackson.core.JsonProcessingException ex) {
throw new IllegalStateException("Could not update invoice template values", ex);
}
}
private String safe(String value) {
@@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
}
BigDecimal previewVatRate = getPreviewVatRate();
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
currentUser, prefixField.getValue(), previewVatRate);
currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData);
showPdfInDialog(pdfBytes);
} catch (Exception ex) {
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
@@ -1479,62 +1484,23 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
}));
}
/**
* Called by JavaScript when the canvas is ready
*/
@ClientCallable
public void onCanvasReady() {
System.out.println("Canvas ready, loading template...");
loadInvoiceTemplate();
}
/**
* Load the saved invoice template from database and display it on the canvas
*/
private void loadInvoiceTemplate() {
try {
java.util.Optional<de.assecutor.votianlt.model.InvoiceTemplate> optionalTemplate = invoiceTemplateService
.getTemplateByUserId(currentUser.getId().toString());
if (optionalTemplate.isPresent()) {
String templateData = optionalTemplate.get().getTemplateData();
if (templateData != null && !templateData.isEmpty()) {
System.out.println("Loading template data: "
+ templateData.substring(0, Math.min(100, templateData.length())) + "...");
// Escape the JSON string for JavaScript
String escapedJson = templateData.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
.replace("\r", "\\r");
// Build masterdata values JSON
String company = safe(currentUser.getCompany());
String fullName = safe(currentUser.getFirstname()) + " " + safe(currentUser.getName());
String street = safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber());
String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity());
String email = safe(currentUser.getEmail());
String phone = safe(currentUser.getPhone());
String templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString())
.getTemplateData();
if (templateData == null || templateData.isBlank()) {
return;
}
String prefix = prefixField.getValue();
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
String masterdataJson = "{" + "'masterdata.company_name': '" + company.replace("'", "\\'") + "',"
+ "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "',"
+ "'masterdata.street': '" + street.replace("'", "\\'") + "'," + "'masterdata.city': '"
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
+ "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}";
BigDecimal rate = currentUser.getVatRate() != null ? currentUser.getVatRate()
: Service.FIXED_VAT_RATE;
double vatRateJs = rate.doubleValue();
getElement().executeJs("setTimeout(function() { "
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
+ " console.log('Loading template into canvas...'); "
+ " window.profileInvoiceServiceData = null; "
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
+ " window.masterdataValues = "
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
+ " window.loadProfileTemplate(templateData); " + " } else { "
+ " console.error('loadProfileTemplate or canvas not available'); " + " } "
+ "}, 300);");
}
} else {
System.out.println("No template found for user: " + currentUser.getId());
}
String masterdataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, invoiceNumber));
getElement().executeJs("window.showProfileInvoiceTemplate($0, $1, JSON.parse($2), JSON.parse($3), $4);",
canvasContainer.getElement(), getElement(), templateData, masterdataJson,
getPreviewVatRate().doubleValue());
} catch (Exception ex) {
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
ex.printStackTrace();
@@ -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();
@@ -263,6 +263,12 @@ public class CustomerInvoiceService {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate) throws Exception {
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, vatRate, null);
}
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate, de.assecutor.votianlt.model.UserInvoiceData billing)
throws Exception {
BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
@@ -270,16 +276,8 @@ public class CustomerInvoiceService {
java.util.Map<String, String> variables = new java.util.HashMap<>();
if (user != null) {
// Use actual user data
String company = user.getCompany();
variables.put("masterdata.company_name", (company != null && !company.isEmpty()) ? company : "Ihre Firma");
variables.put("masterdata.contact_name", safe(user.getFirstname()) + " " + safe(user.getName()));
variables.put("masterdata.street", safe(user.getStreet()) + " " + safe(user.getHouseNumber()));
variables.put("masterdata.city", safe(user.getZip()) + " " + safe(user.getCity()));
variables.put("masterdata.email", safe(user.getEmail()));
variables.put("masterdata.phone", safe(user.getPhone()));
variables.put("masterdata.invoice_number",
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345");
variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing,
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"));
} else {
// Default values for preview without user
variables.put("masterdata.company_name", "Meine Firma GmbH");
@@ -324,7 +322,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -403,6 +401,9 @@ public class CustomerInvoiceService {
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
String fontStyle = element.path("fontStyle").asText("");
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";");
@@ -572,6 +573,14 @@ public class CustomerInvoiceService {
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
html.append("</tr>");
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
.append(vatLabel).append(" USt:</td>");
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal * vatRate.doubleValue()))
.append("</td></tr>");
// Gesamtsumme - label in col 2, value in col 3
html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -626,7 +635,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append(
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }");
@@ -718,6 +727,9 @@ public class CustomerInvoiceService {
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
String fontStyle = element.path("fontStyle").asText("");
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";");
@@ -0,0 +1,61 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.UserInvoiceData;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/** Shared issuer values for the customer editor, PDF preview and actual invoice. */
public final class CustomerInvoiceTemplateVariables {
private CustomerInvoiceTemplateVariables() {
}
public static Map<String, String> create(User user, UserInvoiceData billing, String invoiceNumber) {
Map<String, String> values = new LinkedHashMap<>();
String contact = join(" ", user.getFirstname(), user.getName());
String company = safe(user.getCompany());
if (company.isEmpty()) {
company = contact;
}
String street = join(" ", user.getStreet(), user.getHouseNumber());
String city = join(" ", user.getZip(), user.getCity());
values.put("masterdata.company_name", company);
values.put("masterdata.contact_name", contact);
values.put("masterdata.street", street);
values.put("masterdata.city", city);
values.put("masterdata.email", safe(user.getEmail()));
values.put("masterdata.phone", safe(user.getPhone()));
values.put("masterdata.sender_line", join(" · ", company, street, city));
values.put("masterdata.contact_details", join("\n", user.getEmail(), user.getPhone()));
values.put("masterdata.invoice_number", safe(invoiceNumber));
values.put("masterdata.invoice_date", LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
values.put("masterdata.tax_details", billing == null ? ""
: join("\n", labeled("USt-IdNr.: ", billing.getUstId()),
labeled("Steuernummer: ", billing.getTaxNumber())));
values.put("masterdata.bank_details", billing == null ? ""
: join("\n", billing.getBankName(), labeled("IBAN: ", billing.getIban())));
String intro = billing == null ? "" : safe(billing.getIntroText());
values.put("masterdata.intro", intro.isEmpty() ? "Wir berechnen Ihnen folgende Leistungen:" : intro);
String terms = billing == null ? "" : safe(billing.getPaymentTerms());
values.put("masterdata.payment_terms", terms.isEmpty()
? "Bitte geben Sie bei der Zahlung die Rechnungsnummer an." : terms);
return values;
}
private static String labeled(String label, String value) {
return safe(value).isEmpty() ? "" : label + safe(value);
}
private static String join(String separator, String... parts) {
return Stream.of(parts).map(CustomerInvoiceTemplateVariables::safe).filter(s -> !s.isEmpty())
.collect(Collectors.joining(separator));
}
private static String safe(String value) {
return value == null ? "" : value.strip();
}
}
@@ -1,12 +1,10 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus;
@@ -37,9 +35,7 @@ import de.assecutor.votianlt.repository.TaskTemplateRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SessionAuthenticationService;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
@@ -54,7 +50,6 @@ import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
@@ -68,7 +63,6 @@ public class DemoModeService {
public static final String DEMO_PASSWORD = "demo";
private static final String DEMO_FIRST_NAME = "Demo";
private static final String DEMO_LAST_NAME = "Benutzer";
private static final String DEMO_INVOICE_TEMPLATE_RESOURCE = "templates/demo_invoice_template_default.json";
private final DemoSessionRegistry demoSessionRegistry;
private final SessionAuthenticationService sessionAuthenticationService;
@@ -194,7 +188,6 @@ public class DemoModeService {
List<Service> services = seedDemoServices(demoUser);
List<Customer> customers = seedDemoCustomers(demoUser);
seedDemoJobs(demoUser, customers, services);
seedDemoInvoiceTemplate(demoUser);
}
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
@@ -441,23 +434,6 @@ public class DemoModeService {
return selection;
}
private void seedDemoInvoiceTemplate(User demoUser) {
InvoiceTemplate template = new InvoiceTemplate(demoUser.getId().toHexString(), "Einfach",
createDemoInvoiceTemplateData());
invoiceTemplateRepository.save(template);
}
private String createDemoInvoiceTemplateData() {
try {
String templateJson = new String(
new ClassPathResource(DEMO_INVOICE_TEMPLATE_RESOURCE).getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
return new ObjectMapper().writeValueAsString(templateJson);
} catch (IOException ex) {
throw new IllegalStateException("Could not load demo invoice template", ex);
}
}
private void applyCustomerToPickup(Job job, Customer customer) {
if (job == null || customer == null) {
return;
@@ -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
@@ -35,7 +38,8 @@ public class EmailService {
private String smtpUsername;
@Async
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy) {
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy,
String taskDescription) {
try {
// Load job to get context information
Optional<Job> jobOpt = jobRepository.findById(jobId);
@@ -62,7 +66,7 @@ public class EmailService {
String completedByName = resolveCompletedByName(job, completedBy);
// Send email
sendEmail(user, job, taskType, completedByName);
sendEmail(user, job, taskType, completedByName, taskDescription);
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId);
@@ -72,12 +76,12 @@ public class EmailService {
}
}
private void sendEmail(User user, Job job, String taskType, String completedByName) {
private void sendEmail(User user, Job job, String taskType, String completedByName, String taskDescription) {
SimpleMailMessage message = new SimpleMailMessage();
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,11 +89,14 @@ 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");
}
body.append("Aufgabe: ").append(taskTypeName).append("\n");
if (taskDescription != null && !taskDescription.isBlank()) {
body.append("Beschreibung: ").append(taskDescription.strip()).append("\n");
}
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
String deliveryCities = job.getDeliveryCitiesDisplay();
@@ -195,6 +202,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("Station abgeschlossen - " + 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 +306,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 +316,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 +340,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 +395,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 +405,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 +444,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");
@@ -1,16 +1,23 @@
package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InvoiceTemplateService {
public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT";
private final InvoiceTemplateRepository invoiceTemplateRepository;
/**
@@ -36,6 +43,49 @@ public class InvoiceTemplateService {
return invoiceTemplateRepository.findByUserId(userId);
}
/**
* Customer invoices use the neutral default if no usable personal template is
* stored. Reading the default never creates or overwrites a database record.
*/
public InvoiceTemplate getCustomerTemplateByUserId(String userId) {
if (userId == null || userId.isBlank() || "SYSTEM".equals(userId) || CUSTOMER_DEFAULT_USER_ID.equals(userId)) {
throw new IllegalArgumentException("A customer user ID is required");
}
return invoiceTemplateRepository.findByUserId(userId)
.filter(template -> hasTemplateElements(template.getTemplateData()))
.orElseGet(() -> invoiceTemplateRepository.findByUserId(CUSTOMER_DEFAULT_USER_ID)
.filter(template -> hasTemplateElements(template.getTemplateData()))
// Saving the customer's edits must never change the shared default.
.map(template -> new InvoiceTemplate(userId, template.getName(), template.getTemplateData()))
.orElseGet(() -> loadBundledCustomerDefault(userId)));
}
private InvoiceTemplate loadBundledCustomerDefault(String userId) {
try (var input = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) {
String json = new String(input.readAllBytes(), StandardCharsets.UTF_8);
return new InvoiceTemplate(userId, "Neutral", new ObjectMapper().writeValueAsString(json));
} catch (IOException ex) {
throw new IllegalStateException("Could not load neutral customer invoice template", ex);
}
}
private boolean hasTemplateElements(String templateData) {
if (templateData == null || templateData.isBlank()) {
return false;
}
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode data = mapper.readTree(templateData);
// Templates can be stored as an object or as a JSON-encoded string.
if (data != null && data.isTextual()) {
data = mapper.readTree(data.textValue());
}
return data != null && data.path("elements").isArray() && !data.path("elements").isEmpty();
} catch (IOException ex) {
return false;
}
}
/**
* Check if a template exists for a user
*/
@@ -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();
@@ -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,420 @@
{
"elements": [
{
"id": "neutral-company",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "5.70071",
"widthPercent": "61.51261",
"heightPercent": "3.56295",
"fontSize": 20,
"fontStyle": "bold",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.company_name"
},
{
"id": "neutral-document-label",
"type": "text",
"text": "RECHNUNG",
"xPercent": "71.93277",
"yPercent": "6.88836",
"widthPercent": "19.32773",
"heightPercent": "2.13777",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "right",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-street",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "10.80760",
"widthPercent": "57.14286",
"heightPercent": "1.66271",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.street"
},
{
"id": "neutral-city",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "12.58907",
"widthPercent": "57.14286",
"heightPercent": "1.66271",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.city"
},
{
"id": "neutral-header-rule",
"type": "line",
"text": "",
"xPercent": "8.73950",
"yPercent": "16.50831",
"widthPercent": "82.52101",
"heightPercent": "0.11876",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-sender",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "20.30879",
"widthPercent": "82.52101",
"heightPercent": "1.18765",
"fontSize": 7,
"fontStyle": "normal",
"textAlign": "left",
"color": "#555555",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.sender_line"
},
{
"id": "neutral-recipient-company_name",
"type": "text",
"text": "Kundenfirma GmbH",
"xPercent": "8.73950",
"yPercent": "23.27791",
"widthPercent": "47.89916",
"heightPercent": "1.78147",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": true,
"variable": "customer.company_name"
},
{
"id": "neutral-recipient-contact_name",
"type": "text",
"text": "Erika Mustermann",
"xPercent": "8.73950",
"yPercent": "25.05938",
"widthPercent": "47.89916",
"heightPercent": "1.78147",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": true,
"variable": "customer.contact_name"
},
{
"id": "neutral-recipient-street",
"type": "text",
"text": "Kundenstraße 456",
"xPercent": "8.73950",
"yPercent": "26.84086",
"widthPercent": "47.89916",
"heightPercent": "1.78147",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": true,
"variable": "customer.street"
},
{
"id": "neutral-recipient-city",
"type": "text",
"text": "54321 Kundenstadt",
"xPercent": "8.73950",
"yPercent": "28.62233",
"widthPercent": "47.89916",
"heightPercent": "1.78147",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": true,
"variable": "customer.city"
},
{
"id": "neutral-number-label",
"type": "text",
"text": "RECHNUNGSNUMMER",
"xPercent": "63.52941",
"yPercent": "23.15914",
"widthPercent": "27.73109",
"heightPercent": "1.42518",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-number",
"type": "text",
"text": "",
"xPercent": "63.52941",
"yPercent": "25.05938",
"widthPercent": "27.73109",
"heightPercent": "1.90024",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_number"
},
{
"id": "neutral-date-label",
"type": "text",
"text": "RECHNUNGSDATUM",
"xPercent": "63.52941",
"yPercent": "28.38480",
"widthPercent": "27.73109",
"heightPercent": "1.42518",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-date",
"type": "text",
"text": "",
"xPercent": "63.52941",
"yPercent": "30.28504",
"widthPercent": "27.73109",
"heightPercent": "1.90024",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_date"
},
{
"id": "neutral-services-heading",
"type": "text",
"text": "Leistungen",
"xPercent": "8.73950",
"yPercent": "36.34204",
"widthPercent": "82.52101",
"heightPercent": "2.37530",
"fontSize": 14,
"fontStyle": "bold",
"textAlign": "left",
"color": "#202020",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-intro",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "39.90499",
"widthPercent": "82.52101",
"heightPercent": "3.56295",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.intro"
},
{
"id": "neutral-services",
"type": "text",
"text": "Rechnungspositionen",
"xPercent": "8.73950",
"yPercent": "45.24941",
"widthPercent": "82.52101",
"heightPercent": "27.67221",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "services.list"
},
{
"id": "neutral-payment-label",
"type": "text",
"text": "ZAHLUNG",
"xPercent": "8.73950",
"yPercent": "78.02850",
"widthPercent": "82.52101",
"heightPercent": "1.42518",
"fontSize": 8,
"fontStyle": "bold",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-payment",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "80.40380",
"widthPercent": "82.52101",
"heightPercent": "4.98812",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.payment_terms"
},
{
"id": "neutral-footer-rule",
"type": "line",
"text": "",
"xPercent": "8.73950",
"yPercent": "88.83610",
"widthPercent": "82.52101",
"heightPercent": "0.11876",
"fontSize": 10,
"fontStyle": "normal",
"textAlign": "left",
"color": "#202020",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-contact-label",
"type": "text",
"text": "KONTAKT",
"xPercent": "8.73950",
"yPercent": "90.61758",
"widthPercent": "26.21849",
"heightPercent": "1.30641",
"fontSize": 8,
"fontStyle": "bold",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-contact",
"type": "text",
"text": "",
"xPercent": "8.73950",
"yPercent": "92.87411",
"widthPercent": "26.21849",
"heightPercent": "3.32542",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#444444",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.contact_details"
},
{
"id": "neutral-tax-label",
"type": "text",
"text": "STEUERANGABEN",
"xPercent": "38.65546",
"yPercent": "90.61758",
"widthPercent": "25.37815",
"heightPercent": "1.30641",
"fontSize": 8,
"fontStyle": "bold",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-tax",
"type": "text",
"text": "",
"xPercent": "38.65546",
"yPercent": "92.87411",
"widthPercent": "25.37815",
"heightPercent": "3.32542",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#444444",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.tax_details"
},
{
"id": "neutral-bank-label",
"type": "text",
"text": "BANKVERBINDUNG",
"xPercent": "67.22689",
"yPercent": "90.61758",
"widthPercent": "24.03361",
"heightPercent": "1.30641",
"fontSize": 8,
"fontStyle": "bold",
"textAlign": "left",
"color": "#555555",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "neutral-bank",
"type": "text",
"text": "",
"xPercent": "67.22689",
"yPercent": "92.87411",
"widthPercent": "24.03361",
"heightPercent": "3.32542",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#444444",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.bank_details"
}
]
}
@@ -1,187 +0,0 @@
{
"elements": [
{
"id": "demo-title",
"type": "header",
"text": "Rechnung",
"xPercent": "7.56",
"yPercent": "6.53",
"widthPercent": "28.07",
"heightPercent": "4.04",
"fontSize": 24,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-number-label",
"type": "text",
"text": "Rechnungsnr.",
"xPercent": "67.06",
"yPercent": "7.48",
"widthPercent": "17.98",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-number-value",
"type": "text",
"text": "000000",
"xPercent": "84.03",
"yPercent": "7.48",
"widthPercent": "10.08",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.number"
},
{
"id": "demo-date-label",
"type": "text",
"text": "Datum",
"xPercent": "67.06",
"yPercent": "11.52",
"widthPercent": "17.98",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-date-value",
"type": "text",
"text": "2026-01-01",
"xPercent": "84.03",
"yPercent": "11.52",
"widthPercent": "10.08",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.date"
},
{
"id": "demo-sender",
"type": "text",
"text": null,
"xPercent": "7.56",
"yPercent": "16.51",
"widthPercent": "30.08",
"heightPercent": "2.97",
"fontSize": 12,
"fontStyle": "bold",
"color": "#111111",
"isStatic": true,
"variable": "masterdata.contact_name"
},
{
"id": "demo-customer",
"type": "customer",
"text": "Kundenname\nStraße Nr.\nPLZ Ort",
"xPercent": "7.56",
"yPercent": "25.06",
"widthPercent": "33.95",
"heightPercent": "12.00",
"fontSize": 12,
"fontStyle": "normal",
"color": "#111111"
},
{
"id": "demo-services",
"type": "text",
"text": "Leistungen",
"xPercent": "7.56",
"yPercent": "42.04",
"widthPercent": "85.04",
"heightPercent": "28.03",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "services.list"
},
{
"id": "demo-net-label",
"type": "text",
"text": "Netto",
"xPercent": "67.06",
"yPercent": "73.99",
"widthPercent": "11.93",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-net-value",
"type": "text",
"text": "0,00 €",
"xPercent": "80.00",
"yPercent": "73.99",
"widthPercent": "13.95",
"heightPercent": "2.97",
"fontSize": 11,
"fontStyle": "normal",
"color": "#111111",
"isStatic": true,
"variable": "invoice.net_total"
},
{
"id": "demo-total-label",
"type": "text",
"text": "Gesamt",
"xPercent": "67.06",
"yPercent": "78.98",
"widthPercent": "11.93",
"heightPercent": "3.44",
"fontSize": 13,
"fontStyle": "bold",
"color": "#111111"
},
{
"id": "demo-total-value",
"type": "text",
"text": "0,00 €",
"xPercent": "80.00",
"yPercent": "78.98",
"widthPercent": "13.95",
"heightPercent": "3.44",
"fontSize": 13,
"fontStyle": "bold",
"color": "#111111",
"isStatic": true,
"variable": "invoice.gross_total"
},
{
"id": "demo-template-note",
"type": "text",
"text": "Hinweis: Dieses Rechnungstemplate kann vom Benutzer ",
"xPercent": "0.00",
"yPercent": "20.78",
"widthPercent": "100.00",
"heightPercent": "4.51",
"fontSize": 20,
"fontStyle": "normal",
"textAlign": "center",
"color": "#555555"
},
{
"id": "element-1",
"type": "text",
"text": "frei angepasst werden.",
"xPercent": "31.93",
"yPercent": "24.94",
"widthPercent": "25.21",
"heightPercent": "3.56",
"fontSize": 20,
"color": "#333333",
"isStatic": false,
"isCustomer": false,
"variable": null
}
]
}
@@ -0,0 +1,437 @@
{
"elements": [
{
"id": "hha-letterhead",
"type": "image",
"text": "",
"xPercent": "0.00000",
"yPercent": "0.00000",
"widthPercent": "100.00000",
"heightPercent": "100.00000",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null,
"imageData": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNTk1IiBoZWlnaHQ9Ijg0MiIgdmlld0JveD0iMCAwIDU5NSA4NDIiPgogIDxkZWZzPgogICAgPGltYWdlIGlkPSJvcmlnaW5hbC1sb2dvIiB4PSIwIiB5PSIwIiB3aWR0aD0iNTk1IiBoZWlnaHQ9IjcwLjg4IiB4bGluazpocmVmPSJkYXRhOmltYWdlL2pwZWc7YmFzZTY0LC85ai80QUFRU2taSlJnQUJBUUFBQVFBQkFBRC8yd0JEQUFNQ0FnTUNBZ01EQXdNRUF3TUVCUWdGQlFRRUJRb0hCd1lJREFvTURBc0tDd3NORGhJUURRNFJEZ3NMRUJZUUVSTVVGUlVWREE4WEdCWVVHQklVRlJULzJ3QkRBUU1FQkFVRUJRa0ZCUWtVRFFzTkZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlQvd2dBUkNBSlBFMkVEQVNJQUFoRUJBeEVCLzhRQUhRQUJBUUFEQVFFQkFRRUFBQUFBQUFBQUFBZ0dCd2tGQkFNQkF2L0VBQnNCQVFBQkJRRUFBQUFBQUFBQUFBQUFBQUFEQVFJRUJRWUgvOW9BREFNQkFBSVFBeEFBQUFHcVFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQLine truncated
},
{
"id": "hha-issuer-street",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "13.42043",
"widthPercent": "29.07563",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.street"
},
{
"id": "hha-issuer-city",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "14.98812",
"widthPercent": "29.07563",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.city"
},
{
"id": "hha-phone-label",
"type": "text",
"text": "Tel.:",
"xPercent": "64.36975",
"yPercent": "17.93349",
"widthPercent": "4.36975",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-issuer-phone",
"type": "text",
"text": "",
"xPercent": "68.73950",
"yPercent": "17.93349",
"widthPercent": "24.03361",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.phone"
},
{
"id": "hha-issuer-website",
"type": "text",
"text": "",
"xPercent": "64.36975",
"yPercent": "19.50119",
"widthPercent": "28.57143",
"heightPercent": "1.56770",
"fontSize": 11,
"fontStyle": "bold",
"textAlign": "left",
"color": "#1a11b9",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.website"
},
{
"id": "hha-sender-line",
"type": "text",
"text": "",
"xPercent": "9.57983",
"yPercent": "18.64608",
"widthPercent": "52.94118",
"heightPercent": "0.99762",
"fontSize": 7,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.sender_line"
},
{
"id": "hha-recipient-company_name",
"type": "text",
"text": "Musterfirma GmbH",
"xPercent": "9.57983",
"yPercent": "20.19002",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.company_name"
},
{
"id": "hha-recipient-contact_name",
"type": "text",
"text": "Max Mustermann",
"xPercent": "9.57983",
"yPercent": "21.90024",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.contact_name"
},
{
"id": "hha-recipient-street",
"type": "text",
"text": "Musterstraße 1",
"xPercent": "9.57983",
"yPercent": "23.61045",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.street"
},
{
"id": "hha-recipient-city",
"type": "text",
"text": "12345 Musterstadt",
"xPercent": "9.57983",
"yPercent": "25.32067",
"widthPercent": "51.26050",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": true,
"variable": "customer.city"
},
{
"id": "hha-fax-label",
"type": "text",
"text": "Fax",
"xPercent": "64.36975",
"yPercent": "27.07838",
"widthPercent": "8.40336",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-fax-number",
"type": "text",
"text": "040-181237719",
"xPercent": "64.36975",
"yPercent": "28.50356",
"widthPercent": "9.74790",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-email-label",
"type": "text",
"text": "E-Mail",
"xPercent": "74.45378",
"yPercent": "27.07838",
"widthPercent": "17.81513",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-issuer-email",
"type": "text",
"text": "",
"xPercent": "74.45378",
"yPercent": "28.50356",
"widthPercent": "18.65546",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.email"
},
{
"id": "hha-your-reference",
"type": "text",
"text": "Ihr Zeichen, Ihre Nachricht vom",
"xPercent": "11.42857",
"yPercent": "35.51069",
"widthPercent": "23.19328",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-our-reference",
"type": "text",
"text": "Unser Zeichen, unsere Nachricht vom",
"xPercent": "35.63025",
"yPercent": "35.51069",
"widthPercent": "24.36975",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-contact-label",
"type": "text",
"text": "Telefon, Name",
"xPercent": "59.83193",
"yPercent": "35.51069",
"widthPercent": "16.80672",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-date-label",
"type": "text",
"text": "Datum",
"xPercent": "84.03361",
"yPercent": "35.51069",
"widthPercent": "8.73950",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-invoice-date",
"type": "text",
"text": "",
"xPercent": "84.03361",
"yPercent": "36.93587",
"widthPercent": "8.90756",
"heightPercent": "1.14014",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_date"
},
{
"id": "hha-invoice-title",
"type": "header",
"text": "Rechnung",
"xPercent": "9.57983",
"yPercent": "40.38005",
"widthPercent": "10.75630",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "bold",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-invoice-number",
"type": "text",
"text": "",
"xPercent": "20.33613",
"yPercent": "40.38005",
"widthPercent": "50.42017",
"heightPercent": "1.71021",
"fontSize": 12,
"fontStyle": "bold",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.invoice_number"
},
{
"id": "hha-intro",
"type": "text",
"text": "Gemäß unserem Nutzungsvertrag berechnen wir Ihnen\nfolgende Leistungen:",
"xPercent": "9.57983",
"yPercent": "44.53682",
"widthPercent": "83.36134",
"heightPercent": "3.42043",
"fontSize": 12,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-positions",
"type": "text",
"text": "Rechnungspositionen",
"xPercent": "9.57983",
"yPercent": "49.04988",
"widthPercent": "83.36134",
"heightPercent": "26.72209",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "services.list"
},
{
"id": "hha-payment-label",
"type": "text",
"text": "Zahlungsbedingungen:",
"xPercent": "9.57983",
"yPercent": "85.03563",
"widthPercent": "22.68908",
"heightPercent": "1.71021",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-payment-text",
"type": "text",
"text": "Gesamtbetrag bis spätestens zum 10. Werktag nach\nRechnungserhalt auf unser u. g. Konto.",
"xPercent": "33.27731",
"yPercent": "85.03563",
"widthPercent": "59.66387",
"heightPercent": "3.42043",
"fontSize": 11,
"fontStyle": "normal",
"textAlign": "left",
"color": "#000000",
"isStatic": false,
"isCustomer": false,
"variable": null
},
{
"id": "hha-footer",
"type": "text",
"text": "",
"xPercent": "9.24370",
"yPercent": "92.75534",
"widthPercent": "84.03361",
"heightPercent": "3.42043",
"fontSize": 8,
"fontStyle": "normal",
"textAlign": "center",
"color": "#000000",
"isStatic": true,
"isCustomer": false,
"variable": "masterdata.footer"
}
]
}
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
const generator = readFileSync(new URL('../../main/frontend/invoice-generator/profile-invoice-generator.js', import.meta.url), 'utf8');
function createEditor() {
const painted = [];
const pendingImages = [];
const context = new Proxy({
measureText: text => ({ width: text.length * 6 }),
drawImage: image => painted.push(['image', image.src]),
fillText: text => painted.push(['text', text])
}, { get: (target, key) => target[key] ?? (() => {}) });
const canvas = { style: {}, getContext: () => context, addEventListener() {}, setAttribute() {} };
const container = {
style: {},
getBoundingClientRect: () => ({ width: 615, height: 862 }),
appendChild() {},
addEventListener() {}
};
const window = { addEventListener() {} };
const resizeObservers = [];
class ResizeObserver {
constructor(callback) { this.callback = callback; resizeObservers.push(this); }
observe() {}
disconnect() { this.disconnected = true; }
}
class Image {
width = 595;
height = 842;
constructor() { pendingImages.push(this); }
}
vm.runInNewContext(generator, {
window,
document: { getElementById: () => container, createElement: () => canvas },
Image,
ResizeObserver,
console: { log() {}, error(error) { throw new Error(String(error)); } }
});
window.initProfileInvoiceGenerator();
window.loadProfileTemplate({ elements: [
{ id: 'letterhead', type: 'image', imageData: 'data:image/svg+xml;base64,test', x: 0, y: 0, width: 595, height: 842 },
{ id: 'invoice-text', type: 'text', text: 'Rechnung', x: 50, y: 250, width: 100, height: 30 }
] });
return { window, painted, pendingImages, container, resizeObservers };
}
test('invoice text stays above a letterhead that finishes loading later', () => {
const { painted, pendingImages } = createEditor();
assert.deepEqual(painted, [['text', 'Rechnung']]);
painted.length = 0;
pendingImages[0].onload();
assert.deepEqual(painted, [
['image', 'data:image/svg+xml;base64,test'],
['text', 'Rechnung']
]);
});
test('a removed letterhead is not painted when its pending image finishes loading', () => {
const { window, painted, pendingImages } = createEditor();
window.deleteProfileElement('letterhead');
painted.length = 0;
pendingImages[0].onload();
assert.deepEqual(painted, [['text', 'Rechnung']]);
});
test('optional billing data stays empty while unknown variables retain a placeholder', () => {
const { window } = createEditor();
window.masterdataValues = { 'masterdata.bank_details': '' };
window.loadProfileTemplate({ elements: [
{ id: 'bank', type: 'text', variable: 'masterdata.bank_details', text: 'Old bank', x: 0, y: 0 },
{ id: 'unknown', type: 'text', variable: 'masterdata.unknown', x: 0, y: 20 }
] });
assert.equal(window.profileInvoiceState.elements[0].text, '');
assert.equal(window.profileInvoiceState.elements[1].text, '[unknown]');
});
const neutralTemplate = JSON.parse(readFileSync(new URL('../../main/resources/templates/customer_invoice_template_neutral.json', import.meta.url), 'utf8'));
test('the neutral profile template loads when its initially hidden tab becomes visible', () => {
const { window, container, resizeObservers } = createEditor();
container.getBoundingClientRect = () => ({ width: 0, height: 0 });
window.showProfileInvoiceTemplate(container, {}, JSON.stringify(neutralTemplate), { 'masterdata.company_name': 'Kundenfirma' }, 0.19);
assert.equal(resizeObservers.length, 1);
assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead');
container.getBoundingClientRect = () => ({ width: 615, height: 862 });
resizeObservers[0].callback();
assert.equal(window.profileInvoiceState.elements.length, neutralTemplate.elements.length);
assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company');
assert.equal(window.profileInvoiceState.elements[0].text, 'Kundenfirma');
assert.equal(resizeObservers[0].disconnected, true);
assert.equal(window.pendingProfileInvoiceLoad, null);
});
test('opening another customer profile cancels a pending template load', () => {
const { window, container, resizeObservers } = createEditor();
container.getBoundingClientRect = () => ({ width: 0, height: 0 });
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Vorheriger Kunde' }, 0.19);
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Aktueller Kunde' }, 0.07);
assert.equal(resizeObservers[0].disconnected, true);
container.getBoundingClientRect = () => ({ width: 615, height: 862 });
resizeObservers[0].callback();
assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead');
resizeObservers[1].callback();
assert.equal(window.profileInvoiceState.elements[0].text, 'Aktueller Kunde');
assert.equal(window.profileInvoiceVatRate, 0.07);
});
test('a visible profile canvas loads the template immediately', () => {
const { window, container, resizeObservers } = createEditor();
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Kundenfirma' }, 0.19);
assert.equal(resizeObservers.length, 0);
assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company');
});
@@ -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;
}
}
@@ -0,0 +1,165 @@
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.never;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.BarcodeTask;
import de.assecutor.votianlt.model.task.CommentTask;
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());
job.setCompletedStationOrders(List.of(1));
when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task));
when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job));
Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
"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()));
verify(emailService).sendTaskCompletionNotification(job.getId(), task.getTaskType(), task.getIdAsString(),
actor, "Am Seiteneingang abgeben.");
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())));
}
}
@ParameterizedTest
@MethodSource("tasks")
void optionalTasksOfClosedStationCannotPersistDataOrSendEmail(BaseTask task) {
task.setId(new ObjectId());
task.setOptional(true);
task.setStationOrder(0);
Job job = new Job();
job.setCompletedStationOrders(List.of(0));
rejectCompletion(task, job);
}
@ParameterizedTest
@MethodSource("tasks")
void completedJobRejectsTasksEvenWithoutStationConfirmation(BaseTask task) {
task.setId(new ObjectId());
task.setOptional(true);
Job job = new Job();
job.setStatus(JobStatus.COMPLETED);
rejectCompletion(task, job);
}
@ParameterizedTest
@MethodSource("tasks")
void repeatedCompletionCannotAddAttachmentsOrSendAnotherEmail(BaseTask task) {
task.setId(new ObjectId());
task.setCompleted(true);
rejectCompletion(task, new Job());
}
@ParameterizedTest
@MethodSource("tasks")
void closedStationIsResolvedByIdEvenWhenTaskOrderIsStale(BaseTask task) {
task.setId(new ObjectId());
task.setStationId(new ObjectId());
task.setStationOrder(7);
DeliveryStation station = new DeliveryStation();
station.setStationId(task.getStationId());
station.setStationOrder(0);
Job job = new Job();
job.setDeliveryStations(List.of(station));
job.setCompletedStationOrders(List.of(0));
rejectCompletion(task, job);
}
private void rejectCompletion(BaseTask task, Job job) {
when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task));
when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job));
Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
"signatureSvg", "<svg/>", "commentText", "Late comment");
controller.handleTaskCompleted(Map.of("taskId", task.getIdAsString(), "extraData", extra),
task.getTaskType(), "driver-1");
verify(taskRepository, never()).save(any());
verifyNoInteractions(photoRepository, signatureRepository, barcodeRepository, commentRepository,
emailService, jobHistoryService, jobUpdateBroadcaster);
}
}
@@ -0,0 +1,50 @@
package de.assecutor.votianlt.pages.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.repository.JobRepository;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class OrderNumberTest {
@Mock
private JobRepository jobRepository;
@InjectMocks
private AddJobService service;
@Test
void newOrdersUseTheNewPrefixWithoutRenamingExistingNumbers() {
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Job legacy = new Job();
legacy.setJobNumber("JOB" + date + "001");
when(jobRepository.findAll()).thenReturn(List.of(legacy));
String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber");
assertThat(number).isEqualTo("AUFTRAG" + date + "001");
assertThat(legacy.getJobNumber()).isEqualTo("JOB" + date + "001");
}
@Test
void existingOrderNumbersAreSkipped() {
String prefix = "AUFTRAG" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
Job existing = new Job();
existing.setJobNumber(prefix + "002");
when(jobRepository.findAll()).thenReturn(List.of(existing));
when(jobRepository.existsByJobNumber(prefix + "002")).thenReturn(true);
String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber");
assertThat(number).isEqualTo(prefix + "003");
}
}
@@ -0,0 +1,33 @@
package de.assecutor.votianlt.pages.view;
import static org.assertj.core.api.Assertions.assertThat;
import com.vaadin.flow.component.html.Div;
import de.assecutor.votianlt.model.task.BaseTask;
import de.assecutor.votianlt.model.task.ConfirmationTask;
import de.assecutor.votianlt.model.task.SignatureTask;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.test.util.ReflectionTestUtils;
class JobSummaryTaskDescriptionTest {
static Stream<BaseTask> tasks() {
return Stream.of(new ConfirmationTask("OK"), new SignatureTask());
}
@ParameterizedTest
@MethodSource("tasks")
void completedTaskStillShowsInstructionsInCardAndStationSummary(BaseTask task) {
JobSummaryView view = new JobSummaryView(null, null, null, null, null, null, null, null, null, null,
null, null, null);
task.setDescription("Am Seiteneingang klingeln.\nBeim Empfang abgeben.");
task.setCompleted(true);
Div card = ReflectionTestUtils.invokeMethod(view, "createTaskCard", task, task.getDisplayName());
String summary = ReflectionTestUtils.invokeMethod(view, "summarizeDeliveryTask", task);
assertThat(card.getElement().getTextRecursively()).contains(task.getDescription());
assertThat(summary).contains(task.getDescription());
}
}
@@ -0,0 +1,73 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.canvas.parser.PdfTextExtractor;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.UserInvoiceData;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
class CustomerInvoiceTemplateRenderingTest {
@Test
void neutralTemplateResolvesProfilePreviewAndActualInvoiceData() throws Exception {
String template;
try (var stream = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) {
template = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
User user = new User();
user.setCompany("Logistik & Partner");
user.setStreet("Hafenstraße");
user.setHouseNumber("12");
user.setZip("20457");
user.setCity("Hamburg");
user.setEmail("rechnung@example.com");
UserInvoiceData billing = new UserInvoiceData();
billing.setTaxNumber("12/345/67890");
billing.setBankName("Musterbank");
billing.setIban("DE89 3704 0044 0532 0130 00");
var service = new CustomerInvoiceService();
String preview = extractText(service.generatePdfFromCanvasTemplate(template, user, "RE-",
new BigDecimal("0.19"), billing));
assertThat(preview).contains("Logistik & Partner", "RE-12345", "Kundenfirma GmbH", "124,45 €", "779,45 €",
"Steuernummer: 12/345/67890", "IBAN: DE89 3704 0044 0532 0130 00");
var values = CustomerInvoiceTemplateVariables.create(user, billing, "RE-2026-001");
values.put("customer.company_name", "Kunde & Co.");
values.put("customer.contact_name", "Erika Muster");
values.put("customer.street", "Kundenstraße 1");
values.put("customer.city", "10115 Berlin");
values.put("services.json", "[{\"name\":\"Transport\",\"netAmount\":\"100,00\"}]");
values.put("invoice.net_total", "100,00 €");
values.put("invoice.vat_total", "19,00 €");
values.put("invoice.gross_total", "119,00 €");
values.put("invoice.vat_rate", "19%");
ObjectMapper mapper = new ObjectMapper();
for (var element : mapper.readTree(template).path("elements")) {
String variable = element.path("variable").asText("");
if (!variable.isBlank() && !"services.list".equals(variable)) {
assertThat(values).containsKey(variable);
}
}
String invoice = extractText(service.generatePdfFromCanvasTemplateWithData(
mapper.writeValueAsString(template), values, user));
assertThat(invoice).contains("Logistik & Partner", "Kunde & Co.", "Erika Muster", "RE-2026-001",
"Transport", "119,00 €", "Musterbank", "Steuernummer: 12/345/67890")
.doesNotContain("Kundenfirma GmbH", "Assecutor", "[invoice_date]", "[bank_details]", "null");
}
private String extractText(byte[] pdfBytes) throws Exception {
try (var pdf = new PdfDocument(new PdfReader(new ByteArrayInputStream(pdfBytes)))) {
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
return PdfTextExtractor.getTextFromPage(pdf.getPage(1));
}
}
}
@@ -0,0 +1,56 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.UserInvoiceData;
import org.junit.jupiter.api.Test;
class CustomerInvoiceTemplateVariablesTest {
@Test
void usesCustomerProfileAndBillingDataIncludingMultilineText() {
User user = new User();
user.setCompany("Müller & Söhne");
user.setStreet("Hafenstraße");
user.setHouseNumber("12");
user.setZip("20457");
user.setCity("Hamburg");
user.setEmail("rechnung@example.com");
user.setPhone("040 12345");
UserInvoiceData billing = new UserInvoiceData();
billing.setUstId("DE123456789");
billing.setTaxNumber("12/345/67890");
billing.setBankName("Musterbank");
billing.setIban("DE89 3704 0044 0532 0130 00");
billing.setIntroText("Vielen Dank für Ihren Auftrag.\nWir berechnen folgende Leistungen:");
billing.setPaymentTerms("Bitte binnen 14 Tagen zahlen.");
var values = CustomerInvoiceTemplateVariables.create(user, billing, "RE-2026-0001");
assertThat(values).containsEntry("masterdata.company_name", "Müller & Söhne")
.containsEntry("masterdata.sender_line", "Müller & Söhne · Hafenstraße 12 · 20457 Hamburg")
.containsEntry("masterdata.contact_details", "rechnung@example.com\n040 12345")
.containsEntry("masterdata.tax_details", "USt-IdNr.: DE123456789\nSteuernummer: 12/345/67890")
.containsEntry("masterdata.bank_details", "Musterbank\nIBAN: DE89 3704 0044 0532 0130 00")
.containsEntry("masterdata.intro", billing.getIntroText())
.containsEntry("masterdata.payment_terms", billing.getPaymentTerms())
.containsEntry("masterdata.invoice_number", "RE-2026-0001");
assertThat(values.get("masterdata.invoice_date")).matches("\\d{2}\\.\\d{2}\\.\\d{4}");
}
@Test
void absentBillingDataDoesNotInventTaxOrBankDetails() {
User user = new User();
user.setFirstname("Erika");
user.setName("Beispiel");
var values = CustomerInvoiceTemplateVariables.create(user, null, "RE-1");
assertThat(values).containsEntry("masterdata.company_name", "Erika Beispiel")
.containsEntry("masterdata.tax_details", "").containsEntry("masterdata.bank_details", "")
.containsEntry("masterdata.contact_details", "")
.containsEntry("masterdata.intro", "Wir berechnen Ihnen folgende Leistungen:")
.containsEntry("masterdata.payment_terms", "Bitte geben Sie bei der Zahlung die Rechnungsnummer an.");
assertThat(values.values()).noneMatch(value -> value.contains("null") || value.contains("Assecutor"));
}
}
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -10,7 +11,6 @@ import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.BarcodeRepository;
@@ -90,8 +90,6 @@ class DemoModeServiceTest {
private ArgumentCaptor<List<Job>> jobsCaptor;
@Captor
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
@Captor
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
private DemoSessionRegistry demoSessionRegistry;
private DemoModeService demoModeService;
@@ -127,17 +125,8 @@ class DemoModeServiceTest {
assertThat(prepared).isTrue();
verify(serviceRepository).saveAll(servicesCaptor.capture());
assertThat(servicesCaptor.getValue()).hasSize(2);
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture());
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString());
assertThat(invoiceTemplateCaptor.getValue().getTemplateData())
.contains("elements")
.contains("services.list")
.contains("invoice.gross_total")
.contains("frei angepasst werden")
.contains("textAlign")
.contains("center")
.contains("fontSize")
.contains("20");
// Demo customers use the same neutral default as all other customers.
verify(invoiceTemplateRepository, never()).save(any());
verify(jobRepository).saveAll(jobsCaptor.capture());
assertThat(jobsCaptor.getValue()).hasSize(2);
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
@@ -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;
@@ -18,6 +20,9 @@ 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.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
@@ -55,7 +60,7 @@ class EmailServiceTest {
}
@Test
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
void sendTaskCompletionNotificationIncludesDescriptionAndAssignedAppUserName() {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
@@ -64,13 +69,36 @@ class EmailServiceTest {
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored",
" Ware am Seiteneingang fotografieren.\nAuch die Verpackung dokumentieren. ");
verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText();
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:");
assertThat(body).contains("Aufgabe: Foto-Aufgabe\nBeschreibung: Ware am Seiteneingang fotografieren.\n"
+ "Auch die Verpackung dokumentieren.\nAbgeschlossen von: Max Mustermann");
assertThat(mailCaptor.getValue().getSubject()).isEqualTo("Aufgabe abgeschlossen - JOB-2026-001");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " \n\t " })
void taskCompletionEmailWithoutDescriptionStillSends(String description) {
User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser);
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
emailService.sendTaskCompletionNotification(job.getId(), "CONFIRMATION", "task-1", "ignored", description);
verify(mailSender).send(mailCaptor.capture());
assertThat(mailCaptor.getValue().getText()).contains("Aufgabe: Bestätigung\nAbgeschlossen von: Max Mustermann")
.doesNotContain("Beschreibung:", "null");
}
@Test
@@ -93,6 +121,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("Station abgeschlossen - 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,139 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import java.util.Optional;
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.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class InvoiceTemplateServiceTest {
@Mock
private InvoiceTemplateRepository repository;
private InvoiceTemplateService service;
@BeforeEach
void setUp() {
service = new InvoiceTemplateService(repository);
}
@Test
void missingCustomerTemplateUsesEditableDefaultWithoutWritingToDatabase() throws Exception {
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
assertThat(result.getUserId()).isEqualTo("customer-1");
assertThat(result.getName()).isEqualTo("Neutral");
assertThat(result.getId()).isNull();
ObjectMapper mapper = new ObjectMapper();
var template = mapper.readTree(mapper.readValue(result.getTemplateData(), String.class));
assertThat(template.path("elements").size()).isGreaterThan(10);
assertThat(template.toString()).contains("masterdata.company_name", "customer.company_name", "services.list",
"masterdata.bank_details", "masterdata.tax_details").doesNotContain("Assecutor", "HHA", "DE6720");
verify(repository).findByUserId("customer-1");
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
verifyNoMoreInteractions(repository);
}
@Test
void personalTemplateTakesPrecedence() {
InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Mein Briefpapier",
"{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}");
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal));
assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal);
verify(repository).findByUserId("customer-1");
verifyNoMoreInteractions(repository);
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", "null", "{}", "{\"elements\":[]}", "\"{\\\"elements\\\":[]}\"", "invalid JSON" })
void unusableSavedTemplateFallsBackWithoutOverwritingIt(String data) {
InvoiceTemplate existing = new InvoiceTemplate("customer-1", "Standard", data);
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(existing));
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
assertThat(result.getTemplateData()).contains("neutral-company", "services.list");
assertThat(existing.getTemplateData()).isEqualTo(data);
verify(repository).findByUserId("customer-1");
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
verifyNoMoreInteractions(repository);
}
@Test
void jsonEncodedPersonalTemplateTakesPrecedence() throws Exception {
InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Standard", new ObjectMapper()
.writeValueAsString("{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}"));
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal));
assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal);
}
@Test
void systemTemplateDoesNotFallBackToCustomerDefault() {
when(repository.findByUserId("SYSTEM")).thenReturn(Optional.empty());
assertThat(service.getTemplateByUserId("SYSTEM")).isEmpty();
assertThatThrownBy(() -> service.getCustomerTemplateByUserId("SYSTEM"))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.getCustomerTemplateByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void sharedDefaultIsLoadedAsAnUnsavedCustomerTemplate() {
InvoiceTemplate shared = new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral",
"{\"elements\":[{\"type\":\"text\",\"text\":\"Gemeinsamer Standard\"}]}");
shared.setId("shared-template-id");
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID)).thenReturn(Optional.of(shared));
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
assertThat(result.getUserId()).isEqualTo("customer-1");
assertThat(result.getId()).isNull();
assertThat(result.getTemplateData()).isEqualTo(shared.getTemplateData());
assertThat(shared.getUserId()).isEqualTo(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
verify(repository).findByUserId("customer-1");
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
verifyNoMoreInteractions(repository);
}
@Test
void invalidSharedDefaultUsesBundledNeutralTemplate() {
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID))
.thenReturn(Optional.of(new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral", "{}")));
assertThat(service.getCustomerTemplateByUserId("customer-1").getTemplateData()).contains("neutral-company");
}
@Test
void defaultCanBeSavedAsPersonalTemplate() {
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
String templateData = service.getCustomerTemplateByUserId("customer-1").getTemplateData();
var saved = org.mockito.ArgumentCaptor.forClass(InvoiceTemplate.class);
service.saveTemplate("customer-1", templateData);
verify(repository).save(saved.capture());
assertThat(saved.getValue().getUserId()).isEqualTo("customer-1");
assertThat(saved.getValue().getTemplateData()).isEqualTo(templateData);
}
}
@@ -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;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
{
"_version": "9.1",
"compilerOptions": {
"ignoreDeprecations": "6.0",
"ignoreDeprecations": "5.0",
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
Binary file not shown.
-16
View File
@@ -1,16 +0,0 @@
find . \
-type f \
\( -name "*.java" -o -name "*.dart" -o -name "*.md" \
-o -name "*.yaml" -o -name "*.yml" -o -name "*.properties" \
-o -name "*.xml" -o -name "*.gradle" \) \
! -path "*/build/*" \
! -path "*/.gradle/*" \
! -path "*/.dart_tool/*" \
! -path "*/node_modules/*" \
! -path "*/.git/*" \
| while read file; do
echo "Upload: $file"
curl -s -X POST "http://192.168.180.16:3001/api/v1/document/upload/votianlt" \
-H "Authorization: Bearer W25V4A8-5E24XSH-KKNJBX3-WN07B7P" \
-F "file=@$file"
done