Compare commits

..
3 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
37 changed files with 2132 additions and 419 deletions

No files matched your search

+15 -2
View File
@@ -6,6 +6,7 @@ import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart'; import 'l10n/localization_helpers.dart';
import 'models/delivery_station.dart'; import 'models/delivery_station.dart';
import 'models/job.dart'; import 'models/job.dart';
import 'models/job_progress.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
import 'task_view.dart'; import 'task_view.dart';
import 'widgets/offline_banner.dart'; import 'widgets/offline_banner.dart';
@@ -13,8 +14,10 @@ import 'widgets/offline_banner.dart';
@visibleForTesting @visibleForTesting
Color? deliveryStationCardBackgroundColor( Color? deliveryStationCardBackgroundColor(
DeliveryStation station, DeliveryStation station,
Map<String, bool> taskStatuses, Map<String, bool> taskStatuses, {
) { bool isClosed = false,
}) {
if (isClosed) return AppColors.successSoft;
if (station.tasks.isEmpty) { if (station.tasks.isEmpty) {
return null; return null;
} }
@@ -37,6 +40,7 @@ class CargoItemsView extends StatefulWidget {
class _CargoItemsViewState extends State<CargoItemsView> { class _CargoItemsViewState extends State<CargoItemsView> {
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {}; Map<String, bool> _taskStatuses = const {};
Set<int> _completedStations = const {};
@override @override
void initState() { void initState() {
@@ -46,9 +50,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Future<void> _loadLocalTaskStatuses() async { Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses(); final map = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
widget.job,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_taskStatuses = map; _taskStatuses = map;
_completedStations = completedStations;
}); });
} }
@@ -311,6 +319,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
final backgroundColor = deliveryStationCardBackgroundColor( final backgroundColor = deliveryStationCardBackgroundColor(
station, station,
_taskStatuses, _taskStatuses,
isClosed: JobProgress(
widget.job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations,
).isStationClosed(station.stationOrder),
); );
final title = final title =
station.displayName.isNotEmpty ? station.displayName : station.company; station.displayName.isNotEmpty ? station.displayName : station.company;
+54 -21
View File
@@ -8,6 +8,7 @@ import 'services/dart_mq.dart';
import 'services/chat_service.dart'; import 'services/chat_service.dart';
import 'models/delivery_station.dart'; import 'models/delivery_station.dart';
import 'models/job.dart'; import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart'; import 'models/task.dart';
import 'models/tasks/confirmation_task.dart'; import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart'; import 'models/tasks/photo_task.dart';
@@ -23,6 +24,13 @@ import 'services/database_service.dart';
import 'navigation_observer.dart'; import 'navigation_observer.dart';
import 'routing_view.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 { class JobsView extends StatefulWidget {
const JobsView({super.key}); const JobsView({super.key});
@@ -63,6 +71,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
bool _isLoggingOut = false; bool _isLoggingOut = false;
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
Map<String, bool> _taskStatuses = const {}; Map<String, bool> _taskStatuses = const {};
Map<String, Set<int>> _completedStations = const {};
Map<String, bool> _jobSeen = const {}; Map<String, bool> _jobSeen = const {};
Map<String, double> _jobSwipeOffsets = const {}; Map<String, double> _jobSwipeOffsets = const {};
String? _openJobId; String? _openJobId;
@@ -479,9 +488,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
Future<void> _loadLocalTaskStatuses() async { Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses(); 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; if (!mounted) return;
setState(() { setState(() {
_taskStatuses = map; _taskStatuses = map;
_completedStations = completedStations;
}); });
} }
@@ -950,25 +965,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
statusColor = AppColors.textMuted; 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; final totalTasks = job.tasks.length;
int completedTasks = 0; final completedTasks = progress.completedTaskCount;
for (final t in job.tasks) { final isJobCompleted = progress.isCompleted;
final isCompleted = _taskStatuses[t.id] ?? t.completed; final cardBg = jobCardBackgroundColor(progress);
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;
}
// Build robust display strings with fallbacks // Build robust display strings with fallbacks
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]); final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
final pickupDisplayName = 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) ...[ if (totalTasks > 0) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -1235,7 +1250,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
minHeight: 8, minHeight: 8,
backgroundColor: AppColors.border, backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
completedTasks >= totalTasks isJobCompleted ||
completedTasks >= totalTasks
? AppColors.success ? AppColors.success
: (completedTasks > 0 : (completedTasks > 0
? AppColors.warning ? AppColors.warning
@@ -1447,6 +1463,14 @@ class _JobsViewState extends State<JobsView> with RouteAware {
} }
void _toggleTaskCompletion(Job job, int taskIndex) { 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 // Create a new task with toggled completion status
final updatedTask = job.tasks[taskIndex].copyWith( final updatedTask = job.tasks[taskIndex].copyWith(
completed: !job.tasks[taskIndex].completed, completed: !job.tasks[taskIndex].completed,
@@ -1488,6 +1512,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
id: job.id, id: job.id,
jobNumber: job.jobNumber, jobNumber: job.jobNumber,
status: job.status, status: job.status,
completedStationOrders: job.completedStationOrders,
createdAt: job.createdAt, createdAt: job.createdAt,
updatedAt: job.updatedAt, updatedAt: job.updatedAt,
createdBy: job.createdBy, createdBy: job.createdBy,
@@ -1690,7 +1715,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
const SizedBox(height: 8), const SizedBox(height: 8),
...job.tasks.asMap().entries.map( ...job.tasks.asMap().entries.map(
(entry) => GestureDetector( (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( child: Container(
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
+1 -1
View File
@@ -52,7 +52,7 @@ class DeliveryStation {
final tasks = final tasks =
(json['tasks'] as List? ?? const []).map((rawTask) { (json['tasks'] as List? ?? const []).map((rawTask) {
final taskJson = Map<String, dynamic>.from(rawTask as Map); final taskJson = Map<String, dynamic>.from(rawTask as Map);
taskJson['stationOrder'] ??= stationOrder; taskJson['stationOrder'] = stationOrder;
return Task.fromJson(taskJson); return Task.fromJson(taskJson);
}).toList() }).toList()
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0)); ..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
+10
View File
@@ -6,6 +6,7 @@ class Job {
final String id; // Will store the timestamp as string final String id; // Will store the timestamp as string
final String jobNumber; final String jobNumber;
final String status; final String status;
final List<int> completedStationOrders;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final String createdBy; final String createdBy;
@@ -61,6 +62,7 @@ class Job {
required this.id, required this.id,
required this.jobNumber, required this.jobNumber,
required this.status, required this.status,
this.completedStationOrders = const [],
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.createdBy, required this.createdBy,
@@ -332,6 +334,12 @@ class Job {
id: idValue, id: idValue,
jobNumber: jobJson['jobNumber']?.toString() ?? '', jobNumber: jobJson['jobNumber']?.toString() ?? '',
status: jobJson['status']?.toString() ?? 'UNKNOWN', 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(), createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(), updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
createdBy: jobJson['createdBy']?.toString() ?? '', createdBy: jobJson['createdBy']?.toString() ?? '',
@@ -391,6 +399,7 @@ class Job {
id: id, id: id,
jobNumber: t(jobNumber), jobNumber: t(jobNumber),
status: t(status), status: t(status),
completedStationOrders: List.of(completedStationOrders),
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
createdBy: t(createdBy), createdBy: t(createdBy),
@@ -446,6 +455,7 @@ class Job {
'id': id, 'id': id,
'jobNumber': jobNumber, 'jobNumber': jobNumber,
'status': status, 'status': status,
'completedStationOrders': completedStationOrders,
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
'createdBy': createdBy, 'createdBy': createdBy,
+52
View File
@@ -0,0 +1,52 @@
import 'job.dart';
import 'task.dart';
/// Combines server state with work confirmed locally while offline.
class JobProgress {
final Job job;
final Map<String, bool> taskStatuses;
final Set<int> completedStationOrders;
JobProgress(
this.job, {
this.taskStatuses = const {},
Set<int> localCompletedStations = const {},
}) : completedStationOrders = {
...job.completedStationOrders,
...localCompletedStations,
};
bool isTaskCompleted(Task task) =>
task.completed || taskStatuses[task.id] == true;
bool get isCancelled => job.status.toUpperCase() == 'CANCELLED';
bool get isCompleted {
if (isCancelled) return false;
if (const {'COMPLETED', 'DONE'}.contains(job.status.toUpperCase())) {
return true;
}
// Same rule as the backend: explicit final-station confirmation and all
// mandatory tasks completed. Open optional tasks do not prevent completion.
final lastStationOrder =
job.deliveryStations.isEmpty
? 0
: job.deliveryStations
.map((station) => station.stationOrder)
.reduce((a, b) => a > b ? a : b);
return completedStationOrders.contains(lastStationOrder) &&
job.tasks.where((task) => !task.optional).every(isTaskCompleted);
}
bool isStationClosed(int stationOrder) =>
isCancelled ||
isCompleted ||
completedStationOrders.contains(stationOrder);
bool canCompleteTask(Task task) =>
!isStationClosed(task.stationOrder ?? 0) && !isTaskCompleted(task);
int get completedTaskCount => job.tasks.where(isTaskCompleted).length;
bool get hasProgress =>
completedTaskCount > 0 || completedStationOrders.isNotEmpty;
}
+17
View File
@@ -368,6 +368,23 @@ class DatabaseService {
} }
} }
/// Merge server confirmations and locally persisted offline confirmations.
Future<Set<int>> loadCompletedStationOrders(Job job) async {
final orders =
job.deliveryStations.isEmpty
? {0}
: job.deliveryStations
.map((station) => station.stationOrder)
.toSet();
final completed = <int>{...job.completedStationOrders};
for (final order in orders) {
if (await loadKeyValue('station_completed:${job.id}:$order') == '1') {
completed.add(order);
}
}
return completed;
}
/// Load all task completion statuses /// Load all task completion statuses
Future<Map<String, bool>> loadAllTaskStatuses() async { Future<Map<String, bool>> loadAllTaskStatuses() async {
try { try {
+74 -15
View File
@@ -6,9 +6,11 @@ import 'package:image/image.dart' as img;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'app_theme.dart'; import 'app_theme.dart';
import 'app_state.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart'; import 'l10n/localization_helpers.dart';
import 'models/job.dart'; import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart'; import 'models/task.dart';
import 'models/tasks/confirmation_task.dart'; import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart'; import 'models/tasks/photo_task.dart';
@@ -42,6 +44,9 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> { class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {}; final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService(); final DatabaseService _databaseService = DatabaseService();
late Job _job;
Set<int> _localCompletedStations = {};
StreamSubscription<void>? _jobsUpdatedSubscription;
bool _statusesLoaded = false; bool _statusesLoaded = false;
bool _stationCompleted = false; bool _stationCompleted = false;
bool _isCompletingStation = false; bool _isCompletingStation = false;
@@ -51,31 +56,73 @@ class _TaskViewState extends State<TaskView> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_job = widget.job;
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
(_) => _loadTaskStatuses(),
);
_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 { List<Task> get _visibleTasks {
final stationOrder = widget.stationOrder; final stationOrder = widget.stationOrder;
if (stationOrder == null) { if (stationOrder == null) {
return widget.job.tasks; return _job.tasks;
} }
return widget.job.tasks return _job.tasks
.where((task) => task.stationOrder == stationOrder) .where((task) => task.stationOrder == stationOrder)
.toList(); .toList();
} }
String get _stationCompletionKey => String get _stationCompletionKey =>
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}'; 'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states /// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async { 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 statuses = await _databaseService.loadAllTaskStatuses();
final stationCompleted = final completedStations = await _databaseService.loadCompletedStationOrders(
await _databaseService.loadKeyValue(_stationCompletionKey) == '1'; latestJob,
);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_stationCompleted = _job = latestJob;
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED'; _localCompletedStations = completedStations;
_statusesLoaded = true; _statusesLoaded = true;
_completedTasks.clear(); _completedTasks.clear();
// 1) Add all completed from DB // 1) Add all completed from DB
@@ -85,11 +132,12 @@ class _TaskViewState extends State<TaskView> {
} }
} }
// 2) Merge: also mark tasks completed if the job JSON already had them completed // 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) { if (t.completed) {
_completedTasks.add(t.id); _completedTasks.add(t.id);
} }
} }
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
}); });
} }
@@ -100,7 +148,7 @@ class _TaskViewState extends State<TaskView> {
title: Text( title: Text(
widget.stationTitle?.isNotEmpty == true widget.stationTitle?.isNotEmpty == true
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}' ? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}', : '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
), ),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
@@ -189,7 +237,9 @@ class _TaskViewState extends State<TaskView> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: enabled ? _onCompleteStationPressed : null, onPressed: enabled ? _onCompleteStationPressed : null,
icon: const Icon(Icons.flag), icon: const Icon(Icons.flag),
label: const Text('Station abschließen'), label: Text(
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
@@ -235,12 +285,13 @@ class _TaskViewState extends State<TaskView> {
final stationOrder = widget.stationOrder ?? 0; final stationOrder = widget.stationOrder ?? 0;
try { try {
await StompService().sendStationCompleted( await StompService().sendStationCompleted(
jobId: widget.job.id, jobId: _job.id,
jobNumber: widget.job.jobNumber, jobNumber: _job.jobNumber,
stationOrder: stationOrder, stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks, hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
); );
_stationCompleted = true; _stationCompleted = true;
_localCompletedStations.add(stationOrder);
await _databaseService.saveKeyValue(_stationCompletionKey, '1'); await _databaseService.saveKeyValue(_stationCompletionKey, '1');
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -289,7 +340,7 @@ class _TaskViewState extends State<TaskView> {
final task = _visibleTasks[index]; final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id); final isCompleted = _completedTasks.contains(task.id);
final canBeCompletedNow = final canBeCompletedNow =
!isCompleted && _arePreviousTasksCompleted(index); _canCompleteTask(task) && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status: // Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau // abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
@@ -429,6 +480,7 @@ class _TaskViewState extends State<TaskView> {
} }
void _showTaskCompletionDialog(Task task, int taskIndex) { void _showTaskCompletionDialog(Task task, int taskIndex) {
if (!_canCompleteTask(task)) return;
switch (task) { switch (task) {
case ConfirmationTask(): case ConfirmationTask():
_showConfirmationDialog(task, taskIndex); _showConfirmationDialog(task, taskIndex);
@@ -542,10 +594,12 @@ class _TaskViewState extends State<TaskView> {
(context) => PhotoCaptureScreen( (context) => PhotoCaptureScreen(
task: task, task: task,
onPhotosCompleted: (List<Uint8List> photoData) async { onPhotosCompleted: (List<Uint8List> photoData) async {
if (!_canCompleteTask(task)) return;
// Compress + encode photos for network send (limit payload) // Compress + encode photos for network send (limit payload)
final List<String> base64List = await _compressAndEncodePhotos( final List<String> base64List = await _compressAndEncodePhotos(
photoData, photoData,
); );
if (!_canCompleteTask(task)) return;
final bool truncated = base64List.length < photoData.length; final bool truncated = base64List.length < photoData.length;
// Try to persist full-quality (encoded) photos to DB for offline/backup // Try to persist full-quality (encoded) photos to DB for offline/backup
@@ -647,6 +701,7 @@ class _TaskViewState extends State<TaskView> {
(context) => SignatureCaptureScreen( (context) => SignatureCaptureScreen(
task: task, task: task,
onSignatureCompleted: (String svg, String note) async { onSignatureCompleted: (String svg, String note) async {
if (!_canCompleteTask(task)) return;
try { try {
// Persist SVG only (no PNG) // Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg); await _databaseService.saveTaskSignature(task.id, svg);
@@ -658,6 +713,7 @@ class _TaskViewState extends State<TaskView> {
); );
developer.log('Stack trace: $stackTrace', name: 'TaskView'); developer.log('Stack trace: $stackTrace', name: 'TaskView');
} }
if (!_canCompleteTask(task)) return;
// Store SVG for later use in this TaskView session // Store SVG for later use in this TaskView session
setState(() { setState(() {
_signatureSvgByTask[task.id] = svg; _signatureSvgByTask[task.id] = svg;
@@ -690,6 +746,7 @@ class _TaskViewState extends State<TaskView> {
(context) => BarcodeCaptureScreen( (context) => BarcodeCaptureScreen(
task: task, task: task,
onBarcodesCompleted: (List<String> barcodes) async { onBarcodesCompleted: (List<String> barcodes) async {
if (!_canCompleteTask(task)) return;
try { try {
// Save barcodes to database for later use // Save barcodes to database for later use
await _databaseService.saveTaskBarcodes(task.id, barcodes); await _databaseService.saveTaskBarcodes(task.id, barcodes);
@@ -758,6 +815,8 @@ class _TaskViewState extends State<TaskView> {
String? taskType, String? taskType,
Map<String, dynamic>? extraData, Map<String, dynamic>? extraData,
}) { }) {
final tasks = _visibleTasks.where((task) => task.id == taskId);
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks; final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() { setState(() {
_completedTasks.add(taskId); _completedTasks.add(taskId);
@@ -1000,7 +1059,7 @@ class _TaskViewState extends State<TaskView> {
} }
} }
String _getRemark() => widget.job.remark; String _getRemark() => _job.remark;
String? _getTaskStationLabel(Task task) { String? _getTaskStationLabel(Task task) {
if (widget.stationOrder != null) { if (widget.stationOrder != null) {
@@ -1011,7 +1070,7 @@ class _TaskViewState extends State<TaskView> {
return null; return null;
} }
for (final station in widget.job.deliveryStations) { for (final station in _job.deliveryStations) {
if (station.stationOrder == stationOrder) { if (station.stationOrder == stationOrder) {
final suffix = final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city; station.displayName.isNotEmpty ? station.displayName : station.city;
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.9.24+924 version: 0.9.25+925
environment: environment:
sdk: ^3.7.0 sdk: ^3.7.0
+160
View File
@@ -0,0 +1,160 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
Map<String, dynamic> task(
String id,
int order, {
bool completed = false,
bool optional = false,
}) => {
'id': id,
'jobId': 'job-1',
'stationOrder': order,
'completed': completed,
'optional': optional,
'taskSpecificData': {'taskType': 'CONFIRMATION', 'buttonText': 'Bestätigen'},
};
Job exampleJob({
String status = 'IN_PROGRESS',
List<Object> completedStations = const [],
bool mandatoryDone = true,
}) => Job.fromJson({
'id': 'job-1',
'status': status,
'completedStationOrders': completedStations,
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
task('required', 0, completed: mandatoryDone),
task('optional', 0, optional: true),
],
},
{
'stationOrder': 2,
'tasks': [task('final', 2, completed: true)],
},
],
});
void main() {
test(
'closed intermediate station locks optional tasks but leaves the next station open',
() {
final job = exampleJob(completedStations: [0]);
final progress = JobProgress(job);
expect(progress.isCompleted, isFalse);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(progress.isStationClosed(0), isTrue);
expect(progress.isStationClosed(2), isFalse);
},
);
test(
'server completion is authoritative despite unfinished optional tasks',
() {
final job = exampleJob(status: 'COMPLETED');
final progress = JobProgress(job);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
expect(progress.canCompleteTask(job.tasks[1]), isFalse);
expect(job.tasks[1].completed, isFalse);
},
);
test(
'all mandatory tasks alone do not complete the job without station confirmation',
() {
expect(JobProgress(exampleJob()).isCompleted, isFalse);
},
);
test(
'final station confirmation completes the job even with optional tasks open',
() {
expect(
JobProgress(exampleJob(completedStations: [0, 2])).isCompleted,
isTrue,
);
},
);
test(
'offline final station confirmation uses locally completed mandatory tasks',
() {
final progress = JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {0, 2},
taskStatuses: {'required': true},
);
expect(progress.isCompleted, isTrue);
expect(progress.completedTaskCount, 2);
},
);
test(
'final confirmation cannot bypass unfinished mandatory work at an earlier station',
() {
expect(
JobProgress(
exampleJob(mandatoryDone: false),
localCompletedStations: {2},
).isCompleted,
isFalse,
);
},
);
test(
'completed station confirmations survive normalization and JSON storage',
() {
final saved =
exampleJob(
completedStations: [0, '2', 2, 'invalid'],
).normalized().toJson();
final restored = Job.fromJson(saved);
expect(restored.completedStationOrders, [0, 2]);
expect(JobProgress(restored).isStationClosed(0), isTrue);
},
);
test(
'legacy job with no stations completes only after explicit station zero confirmation',
() {
final job = Job.fromJson({
'id': 'legacy',
'status': 'IN_PROGRESS',
'tasks': [task('optional', 0, optional: true)],
});
expect(JobProgress(job).isCompleted, isFalse);
expect(JobProgress(job, localCompletedStations: {0}).isCompleted, isTrue);
},
);
test('cancelled jobs remain locked and are not reported as completed', () {
final progress = JobProgress(
exampleJob(status: 'CANCELLED'),
localCompletedStations: {0, 2},
);
expect(progress.isCompleted, isFalse);
expect(progress.isStationClosed(2), isTrue);
});
test('embedded station order takes precedence over a stale task order', () {
final job = Job.fromJson({
'id': 'job-1',
'completedStationOrders': [0],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [task('optional', 8, optional: true)],
},
{'stationOrder': 1, 'tasks': []},
],
});
expect(job.tasks.single.stationOrder, 0);
expect(JobProgress(job).canCompleteTask(job.tasks.single), isFalse);
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:votianlt_app/app_theme.dart';
import 'package:votianlt_app/cargo_items_view.dart';
import 'package:votianlt_app/jobs_view.dart';
import 'package:votianlt_app/models/job.dart';
import 'package:votianlt_app/models/job_progress.dart';
import 'package:votianlt_app/task_view.dart';
Job jobWithOptionalTask({
bool closedStation = true,
String status = 'IN_PROGRESS',
}) => Job.fromJson({
'id': 'job-1',
'jobNumber': 'TEST-1',
'status': status,
'completedStationOrders': closedStation ? [0] : [],
'deliveryStations': [
{
'stationOrder': 0,
'tasks': [
{
'id': 'optional',
'jobId': 'job-1',
'optional': true,
'completed': false,
'description': 'Optionale Bestätigung',
'taskOrder': 0,
'taskSpecificData': {
'taskType': 'CONFIRMATION',
'buttonText': 'Jetzt bestätigen',
},
},
],
},
{'stationOrder': 1, 'tasks': []},
],
});
void main() {
for (final status in ['IN_PROGRESS', 'COMPLETED']) {
testWidgets(
'optional task cannot open a dialog after station closure ($status)',
(tester) async {
final job = jobWithOptionalTask(status: status);
await tester.pumpWidget(
MaterialApp(home: TaskView(job: job, stationOrder: 0)),
);
await tester.pump();
final task = find.text('Optionale Bestätigung');
expect(task, findsOneWidget);
final tile = tester.widget<InkWell>(
find.ancestor(of: task, matching: find.byType(InkWell)).first,
);
expect(tile.onTap, isNull);
await tester.tap(task);
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
testWidgets('optional task remains executable at an open station', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: TaskView(
job: jobWithOptionalTask(closedStation: false),
stationOrder: 0,
),
),
);
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('Jetzt bestätigen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets(
'an already open dialog cannot complete a task after the job closes',
(tester) async {
Widget view(Job job) => MaterialApp(
home: TaskView(
key: const ValueKey('task-view'),
job: job,
stationOrder: 0,
),
);
await tester.pumpWidget(view(jobWithOptionalTask(closedStation: false)));
await tester.pump();
await tester.tap(find.text('Optionale Bestätigung'));
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.pumpWidget(view(jobWithOptionalTask(status: 'COMPLETED')));
await tester.pump();
await tester.tap(find.text('Jetzt bestätigen'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
final taskCard = tester.widget<Card>(
find
.ancestor(
of: find.text('Optionale Bestätigung'),
matching: find.byType(Card),
)
.first,
);
expect(
taskCard.elevation,
0,
); // still open and locked, never marked completed
expect(find.text('Station abgeschlossen'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
},
);
test('completed job card is green with optional tasks still open', () {
final job = jobWithOptionalTask(status: 'COMPLETED');
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.successSoft);
expect(job.tasks.single.completed, isFalse);
});
test('offline final station completion makes the job card green', () {
final job = jobWithOptionalTask();
expect(
jobCardBackgroundColor(JobProgress(job, localCompletedStations: {1})),
AppColors.successSoft,
);
expect(jobCardBackgroundColor(JobProgress(job)), AppColors.warningSoft);
expect(
deliveryStationCardBackgroundColor(
job.deliveryStations.first,
{},
isClosed: true,
),
AppColors.successSoft,
);
});
}
+2 -1
View File
@@ -64,7 +64,8 @@ cd "${SCRIPT_DIR}"
echo "Verwende Release-Version ${VERSION}." echo "Verwende Release-Version ${VERSION}."
echo "Baue Production-JAR für 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_REL="target/votianlt-${VERSION}.jar"
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}" JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.24</revision> <revision>0.9.26</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
Binary file not shown.
Binary file not shown.
@@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || {
initialized: false 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 containerId = 'invoice-canvas-container-profile';
var container = document.getElementById(containerId); container = container || document.getElementById(containerId);
if (!container) { if (!container) {
console.error('Canvas container not found:', containerId); console.error('Canvas container not found:', containerId);
@@ -233,28 +264,9 @@ window.initProfileInvoiceGenerator = function() {
var img = new Image(); var img = new Image();
img.onload = function() { img.onload = function() {
imageCache[el.imageData] = img; imageCache[el.imageData] = img;
// Calculate dimensions to maintain aspect ratio // Redraw in element order: a letterhead loaded asynchronously must
var imgAspect = img.width / img.height; // stay behind the invoice text, including after resize or removal.
var boxAspect = w / h; draw();
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);
}
}; };
img.src = el.imageData; img.src = el.imageData;
} }
@@ -1322,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() {
} else if (el.variable.startsWith('masterdata.')) { } else if (el.variable.startsWith('masterdata.')) {
// Masterdata variables - use actual value from masterdata or placeholder // Masterdata variables - use actual value from masterdata or placeholder
var value = masterdata[el.variable]; var value = masterdata[el.variable];
if (value && value.trim() !== '') { if (typeof value === 'string') {
el.text = value; el.text = value;
} else { } else {
el.text = '[' + el.variable.replace('masterdata.', '') + ']'; el.text = '[' + el.variable.replace('masterdata.', '') + ']';
@@ -191,6 +191,17 @@ public class MessageController {
} }
public void handleTaskCompleted(Map<String, Object> payload, String taskType, String appUserId) { 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(); String key = taskType == null ? "" : taskType.trim().toUpperCase();
switch (key) { switch (key) {
@@ -378,15 +389,14 @@ public class MessageController {
return; return;
} }
BaseTask task = opt.get(); BaseTask task = opt.get();
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) {
return;
}
task.setCompletedBy(resolveTaskActor(task, appUserId)); task.setCompletedBy(resolveTaskActor(task, appUserId));
task.setCompleted(true); task.setCompleted(true);
task.setCompletedAt(LocalDateTime.now()); task.setCompletedAt(LocalDateTime.now());
taskRepository.save(task); 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(); ObjectId jobId = jobOpt.get().getId();
// Log detailed task completion in job history // Log detailed task completion in job history
@@ -405,7 +415,8 @@ public class MessageController {
try { try {
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
String completedBy = resolveTaskActor(task, appUserId); String completedBy = resolveTaskActor(task, appUserId);
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy); emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy,
task.getDescription());
// Job completion is no longer auto-triggered by task completion. // Job completion is no longer auto-triggered by task completion.
// It is now driven by explicit station_completed messages from the app // It is now driven by explicit station_completed messages from the app
// (see handleStationCompleted). // (see handleStationCompleted).
@@ -420,6 +431,19 @@ public class MessageController {
} }
} }
private boolean canCompleteTask(BaseTask task, Job job) {
if (task.isCompleted() || job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED) {
return false;
}
int stationOrder = task.getStationOrder() != null ? task.getStationOrder() : 0;
if (task.getStationId() != null && job.getDeliveryStations() != null) {
stationOrder = job.getDeliveryStations().stream().filter(Objects::nonNull)
.filter(station -> task.getStationId().equals(station.getStationId()))
.mapToInt(DeliveryStation::getStationOrder).findFirst().orElse(stationOrder);
}
return job.getCompletedStationOrders() == null || !job.getCompletedStationOrders().contains(stationOrder);
}
private String resolveTaskActor(BaseTask task, String appUserId) { private String resolveTaskActor(BaseTask task, String appUserId) {
if (appUserId != null && !appUserId.isBlank()) { if (appUserId != null && !appUserId.isBlank()) {
return appUserId; return appUserId;
@@ -52,15 +52,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
companyName = new TextField(getTranslation("profile.company")); companyName = new TextField(getTranslation("profile.company"));
companyName.setWidthFull(); companyName.setWidthFull();
// Steuernummer (Pflichtfeld) // Steuernummer (optional)
taxNumber = new TextField(getTranslation("profile.billing.taxnumber")); taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
taxNumber.setRequiredIndicatorVisible(true);
taxNumber.setWidthFull(); taxNumber.setWidthFull();
taxNumber.addBlurListener(e -> validateField(taxNumber));
// USt-IdNr. (Pflichtfeld) // USt-IdNr. (optional)
ustId = new TextField(getTranslation("profile.billing.ustid")); ustId = new TextField(getTranslation("profile.billing.ustid"));
ustId.setRequiredIndicatorVisible(true);
ustId.setPlaceholder("DE123456789"); ustId.setPlaceholder("DE123456789");
ustId.setWidthFull(); ustId.setWidthFull();
ustId.addBlurListener(e -> validateUstId()); ustId.addBlurListener(e -> validateUstId());
@@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
private void configureBinder() { private void configureBinder() {
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName); binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required")) binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber);
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required")) binder.forField(ustId)
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, .withValidator(value -> value == null || value.isBlank()
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid")) getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId); .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). * (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
*/ */
private void validateUstId() { private void validateUstId() {
String value = ustId.getValue(); String value = ustId.getValue();
if (value == null || value.trim().isEmpty()) { if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
ustId.setInvalid(true); ustId.setInvalid(true);
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid")); ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
} else { } else {
@@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
} }
private boolean validateAllFields() { private boolean validateAllFields() {
validateField(taxNumber);
validateUstId(); validateUstId();
validateField(firstName); validateField(firstName);
validateField(lastName); validateField(lastName);
@@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
validateField(city); validateField(city);
validateEmail(); 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() && !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
&& !zip.isInvalid() && !city.isInvalid(); && !zip.isInvalid() && !city.isInvalid();
} }
@@ -33,6 +33,7 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.EmailService; import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
@@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
currentUser = currentUserOpt.get(); currentUser = currentUserOpt.get();
Optional<InvoiceTemplate> templateOpt = invoiceTemplateService InvoiceTemplate template = invoiceTemplateService
.getTemplateByUserId(currentUser.getId().toString()); .getCustomerTemplateByUserId(currentUser.getId().toString());
if (templateOpt.isEmpty()) { String templateData = template.getTemplateData();
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END);
return;
}
String templateData = templateOpt.get().getTemplateData();
if (templateData == null || templateData.isBlank()) { if (templateData == null || templateData.isBlank()) {
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000, Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
Notification.Position.BOTTOM_END); Notification.Position.BOTTOM_END);
@@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
BigDecimal totalAmount = netAmount.add(vatAmount); BigDecimal totalAmount = netAmount.add(vatAmount);
// Build variable substitution map // Build variable substitution map
Map<String, String> variables = new HashMap<>(); Map<String, String> variables = CustomerInvoiceTemplateVariables.create(currentUser,
userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber);
// 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()));
// Customer data (from job - look up Customer entity for full address) // Customer data (from job - look up Customer entity for full address)
String customerSelection = currentJob.getCustomerSelection(); 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.Button;
import com.vaadin.flow.component.button.ButtonVariant; 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.dialog.Dialog;
import com.vaadin.flow.component.formlayout.FormLayout; import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.html.H2; 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); private final Binder<Customer> binder = new Binder<>(Customer.class);
// Form fields - labels set in constructor via setLabel() // 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 companyNameField = new TextField();
private final TextField taxNumberField = new TextField(); private final TextField taxNumberField = new TextField();
private final TextField ustIdField = new TextField(); private final TextField ustIdField = new TextField();
@@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
// Set field labels via i18n // Set field labels via i18n
titleField.setLabel(getTranslation("addjob.address.salutation")); 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")); companyNameField.setLabel(getTranslation("profile.company"));
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber")); taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.setRequiredIndicatorVisible(true);
ustIdField.setLabel(getTranslation("profile.billing.ustid")); ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setRequiredIndicatorVisible(true);
ustIdField.setPlaceholder("DE123456789"); ustIdField.setPlaceholder("DE123456789");
firstnameField.setLabel(getTranslation("profile.firstname")); firstnameField.setLabel(getTranslation("profile.firstname"));
lastNameField.setLabel(getTranslation("profile.lastname")); lastNameField.setLabel(getTranslation("profile.lastname"));
@@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
private void setupBinder() { private void setupBinder() {
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle); binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName); binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required")) binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber);
.bind(Customer::getTaxNumber, Customer::setTaxNumber); binder.forField(ustIdField)
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required")) .withValidator(value -> value == null || value.isBlank()
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, || de.assecutor.votianlt.util.VatIdUtil.isValid(value),
getTranslation("profile.validation.ustid.invalid")) getTranslation("profile.validation.ustid.invalid"))
.bind(Customer::getUstId, Customer::setUstId); .bind(Customer::getUstId, Customer::setUstId);
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname); 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.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService; import de.assecutor.votianlt.service.LanguageService;
import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.Grid;
@@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private Checkbox billingEnabled; private Checkbox billingEnabled;
private NumberField vatRateField; private NumberField vatRateField;
private VerticalLayout propertiesPanelProfile; private VerticalLayout propertiesPanelProfile;
private final Div canvasContainer;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
private Grid<Service> servicesGrid; 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)"); .set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
// Mitte: Canvas // Mitte: Canvas
Div canvasContainer = new Div(); canvasContainer = new Div();
canvasContainer.setId("invoice-canvas-container-profile"); canvasContainer.setId("invoice-canvas-container-profile");
canvasContainer.setWidth("100%"); canvasContainer.setWidth("100%");
canvasContainer.setHeight("100%"); canvasContainer.setHeight("100%");
@@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
// Bestehende Rechnungsdaten laden (nur für die Checkbox) // Bestehende Rechnungsdaten laden (nur für die Checkbox)
loadInvoiceData(); loadInvoiceData();
// Initialize invoice generator when the billing tab is selected // Load directly; the client waits until the billing canvas is visible.
// Also register this view instance for JavaScript callbacks
tabSheet.addSelectedChangeListener(e -> { tabSheet.addSelectedChangeListener(e -> {
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) { if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;" loadInvoiceTemplate();
+ "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());
} }
}); });
@@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(), currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(), prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.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) { private String safe(String value) {
@@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} }
BigDecimal previewVatRate = getPreviewVatRate(); BigDecimal previewVatRate = getPreviewVatRate();
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData, byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
currentUser, prefixField.getValue(), previewVatRate); currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData);
showPdfInDialog(pdfBytes); showPdfInDialog(pdfBytes);
} catch (Exception ex) { } catch (Exception ex) {
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()), 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 * Load the saved invoice template from database and display it on the canvas
*/ */
private void loadInvoiceTemplate() { private void loadInvoiceTemplate() {
try { try {
java.util.Optional<de.assecutor.votianlt.model.InvoiceTemplate> optionalTemplate = invoiceTemplateService String templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString())
.getTemplateByUserId(currentUser.getId().toString()); .getTemplateData();
if (optionalTemplate.isPresent()) { if (templateData == null || templateData.isBlank()) {
String templateData = optionalTemplate.get().getTemplateData(); return;
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 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 prefix = prefixField.getValue();
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
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) { } catch (Exception ex) {
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage()); System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
ex.printStackTrace(); ex.printStackTrace();
@@ -263,6 +263,12 @@ public class CustomerInvoiceService {
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user, public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
String invoicePrefix, BigDecimal vatRate) throws Exception { 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 BigDecimal effectiveVatRate = vatRate != null ? vatRate
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19")); : (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<>(); java.util.Map<String, String> variables = new java.util.HashMap<>();
if (user != null) { if (user != null) {
// Use actual user data variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing,
String company = user.getCompany(); (invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"));
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");
} else { } else {
// Default values for preview without user // Default values for preview without user
variables.put("masterdata.company_name", "Meine Firma GmbH"); variables.put("masterdata.company_name", "Meine Firma GmbH");
@@ -324,7 +322,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>"); htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }"); htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append( 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(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }"); htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }"); 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)) htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;"); .append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;"); 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:") htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); .append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";"); htmlBuilder.append("color:").append(color).append(";");
@@ -572,6 +573,14 @@ public class CustomerInvoiceService {
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>"); .append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
html.append("</tr>"); 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 // Gesamtsumme - label in col 2, value in col 3
html.append("<tr>"); html.append("<tr>");
html.append("<td style='width:55%;padding:2px 0;'></td>"); html.append("<td style='width:55%;padding:2px 0;'></td>");
@@ -626,7 +635,7 @@ public class CustomerInvoiceService {
htmlBuilder.append("<style>"); htmlBuilder.append("<style>");
htmlBuilder.append("@page { size: A4; margin: 0; }"); htmlBuilder.append("@page { size: A4; margin: 0; }");
htmlBuilder.append( 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(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }"); htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
htmlBuilder.append(".line { border-top: 1px solid #333; }"); 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)) htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
.append("mm;"); .append("mm;");
htmlBuilder.append("font-size:").append(fontSize).append("pt;"); 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:") htmlBuilder.append("line-height:")
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;"); .append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
htmlBuilder.append("color:").append(color).append(";"); 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; package de.assecutor.votianlt.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.assecutor.votianlt.model.AppUser; import de.assecutor.votianlt.model.AppUser;
import de.assecutor.votianlt.model.Barcode; import de.assecutor.votianlt.model.Barcode;
import de.assecutor.votianlt.model.Comment; import de.assecutor.votianlt.model.Comment;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
import de.assecutor.votianlt.model.DeliveryStation; import de.assecutor.votianlt.model.DeliveryStation;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.Job; import de.assecutor.votianlt.model.Job;
import de.assecutor.votianlt.model.JobServiceSelection; import de.assecutor.votianlt.model.JobServiceSelection;
import de.assecutor.votianlt.model.JobStatus; 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.UserInvoiceDataRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SessionAuthenticationService; import de.assecutor.votianlt.security.SessionAuthenticationService;
import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; import java.time.LocalTime;
@@ -54,7 +50,6 @@ import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -68,7 +63,6 @@ public class DemoModeService {
public static final String DEMO_PASSWORD = "demo"; public static final String DEMO_PASSWORD = "demo";
private static final String DEMO_FIRST_NAME = "Demo"; private static final String DEMO_FIRST_NAME = "Demo";
private static final String DEMO_LAST_NAME = "Benutzer"; 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 DemoSessionRegistry demoSessionRegistry;
private final SessionAuthenticationService sessionAuthenticationService; private final SessionAuthenticationService sessionAuthenticationService;
@@ -194,7 +188,6 @@ public class DemoModeService {
List<Service> services = seedDemoServices(demoUser); List<Service> services = seedDemoServices(demoUser);
List<Customer> customers = seedDemoCustomers(demoUser); List<Customer> customers = seedDemoCustomers(demoUser);
seedDemoJobs(demoUser, customers, services); seedDemoJobs(demoUser, customers, services);
seedDemoInvoiceTemplate(demoUser);
} }
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) { private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
@@ -441,23 +434,6 @@ public class DemoModeService {
return selection; 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) { private void applyCustomerToPickup(Job job, Customer customer) {
if (job == null || customer == null) { if (job == null || customer == null) {
return; return;
@@ -38,7 +38,8 @@ public class EmailService {
private String smtpUsername; private String smtpUsername;
@Async @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 { try {
// Load job to get context information // Load job to get context information
Optional<Job> jobOpt = jobRepository.findById(jobId); Optional<Job> jobOpt = jobRepository.findById(jobId);
@@ -65,7 +66,7 @@ public class EmailService {
String completedByName = resolveCompletedByName(job, completedBy); String completedByName = resolveCompletedByName(job, completedBy);
// Send email // 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(), log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
taskId); taskId);
@@ -75,7 +76,7 @@ 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(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
@@ -93,6 +94,9 @@ public class EmailService {
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n"); body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
} }
body.append("Aufgabe: ").append(taskTypeName).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"); body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
String deliveryCities = job.getDeliveryCitiesDisplay(); String deliveryCities = job.getDeliveryCitiesDisplay();
@@ -227,7 +231,7 @@ public class EmailService {
SimpleMailMessage message = new SimpleMailMessage(); SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(smtpUsername); message.setFrom(smtpUsername);
message.setTo(user.getEmail()); message.setTo(user.getEmail());
message.setSubject("Abgeschlossene Station - " + jobNumber + " - Station " + (stationOrder + 1)); message.setSubject("Station abgeschlossen - " + jobNumber + " - Station " + (stationOrder + 1));
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
body.append("Hallo ").append(buildFullName(user)).append(",\n\n"); body.append("Hallo ").append(buildFullName(user)).append(",\n\n");
body.append("die folgende Station wurde abgeschlossen:\n\n"); body.append("die folgende Station wurde abgeschlossen:\n\n");
@@ -1,16 +1,23 @@
package de.assecutor.votianlt.service; 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.model.InvoiceTemplate;
import de.assecutor.votianlt.repository.InvoiceTemplateRepository; import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional; import java.util.Optional;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class InvoiceTemplateService { public class InvoiceTemplateService {
public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT";
private final InvoiceTemplateRepository invoiceTemplateRepository; private final InvoiceTemplateRepository invoiceTemplateRepository;
/** /**
@@ -36,6 +43,49 @@ public class InvoiceTemplateService {
return invoiceTemplateRepository.findByUserId(userId); 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 * Check if a template exists for a user
*/ */
@@ -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');
});
@@ -5,9 +5,14 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify; 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 static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.Job; 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.BaseTask;
import de.assecutor.votianlt.model.task.BarcodeTask; import de.assecutor.votianlt.model.task.BarcodeTask;
import de.assecutor.votianlt.model.task.CommentTask; import de.assecutor.votianlt.model.task.CommentTask;
@@ -73,6 +78,7 @@ class TaskCompletionHistoryTest {
task.setCompletedBy("Unknown"); task.setCompletedBy("Unknown");
Job job = new Job(); Job job = new Job();
job.setId(new ObjectId()); job.setId(new ObjectId());
job.setCompletedStationOrders(List.of(1));
when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task)); when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task));
when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job)); when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job));
Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"), Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
@@ -87,6 +93,8 @@ class TaskCompletionHistoryTest {
verify(taskRepository).save(task); verify(taskRepository).save(task);
verify(jobHistoryService).logTaskCompletion(eq(job.getId()), eq(task.getTaskType()), eq(task.getIdAsString()), verify(jobHistoryService).logTaskCompletion(eq(job.getId()), eq(task.getTaskType()), eq(task.getIdAsString()),
eq(actor), eq(task.getDisplayName()), anyString(), eq(task.getDescription())); 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) { if (task instanceof PhotoTask) {
verify(photoRepository).save(argThat(photo -> actor.equals(photo.getCompletedBy()))); verify(photoRepository).save(argThat(photo -> actor.equals(photo.getCompletedBy())));
} else if (task instanceof SignatureTask) { } else if (task instanceof SignatureTask) {
@@ -98,4 +106,60 @@ class TaskCompletionHistoryTest {
verify(commentRepository).save(argThat(comment -> actor.equals(comment.getCompletedBy()))); 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,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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; 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.Job;
import de.assecutor.votianlt.model.JobStatus; import de.assecutor.votianlt.model.JobStatus;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.pages.domain.CustomerRepository; import de.assecutor.votianlt.pages.domain.CustomerRepository;
import de.assecutor.votianlt.repository.AppUserRepository; import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.BarcodeRepository; import de.assecutor.votianlt.repository.BarcodeRepository;
@@ -90,8 +90,6 @@ class DemoModeServiceTest {
private ArgumentCaptor<List<Job>> jobsCaptor; private ArgumentCaptor<List<Job>> jobsCaptor;
@Captor @Captor
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor; private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
@Captor
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
private DemoSessionRegistry demoSessionRegistry; private DemoSessionRegistry demoSessionRegistry;
private DemoModeService demoModeService; private DemoModeService demoModeService;
@@ -127,17 +125,8 @@ class DemoModeServiceTest {
assertThat(prepared).isTrue(); assertThat(prepared).isTrue();
verify(serviceRepository).saveAll(servicesCaptor.capture()); verify(serviceRepository).saveAll(servicesCaptor.capture());
assertThat(servicesCaptor.getValue()).hasSize(2); assertThat(servicesCaptor.getValue()).hasSize(2);
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture()); // Demo customers use the same neutral default as all other customers.
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString()); verify(invoiceTemplateRepository, never()).save(any());
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");
verify(jobRepository).saveAll(jobsCaptor.capture()); verify(jobRepository).saveAll(jobsCaptor.capture());
assertThat(jobsCaptor.getValue()).hasSize(2); assertThat(jobsCaptor.getValue()).hasSize(2);
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber) assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
@@ -20,6 +20,9 @@ import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; 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.ArgumentCaptor;
import org.mockito.Captor; import org.mockito.Captor;
import org.mockito.Mock; import org.mockito.Mock;
@@ -57,7 +60,7 @@ class EmailServiceTest {
} }
@Test @Test
void sendTaskCompletionNotificationUsesAssignedAppUserName() { void sendTaskCompletionNotificationIncludesDescriptionAndAssignedAppUserName() {
User webUser = createWebUser(); User webUser = createWebUser();
AppUser assignedAppUser = createAssignedAppUser(); AppUser assignedAppUser = createAssignedAppUser();
Job job = createJob(webUser, assignedAppUser); Job job = createJob(webUser, assignedAppUser);
@@ -66,7 +69,8 @@ class EmailServiceTest {
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser)); when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser)); 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()); verify(mailSender).send(mailCaptor.capture());
String body = mailCaptor.getValue().getText(); String body = mailCaptor.getValue().getText();
@@ -74,6 +78,27 @@ class EmailServiceTest {
assertThat(body).contains("Abgeschlossen von: Max Mustermann"); assertThat(body).contains("Abgeschlossen von: Max Mustermann");
assertThat(body).contains("Hallo Dr. Anna Unternehmer,"); assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
assertThat(body).contains("Auftrag: ").doesNotContain("Job:"); 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 @Test
@@ -134,7 +159,7 @@ class EmailServiceTest {
SimpleMailMessage mail = mailCaptor.getValue(); SimpleMailMessage mail = mailCaptor.getValue();
assertThat(mail.getTo()).containsExactly("anna@example.com"); assertThat(mail.getTo()).containsExactly("anna@example.com");
assertThat(mail.getFrom()).isEqualTo("noreply@example.com"); assertThat(mail.getFrom()).isEqualTo("noreply@example.com");
assertThat(mail.getSubject()).isEqualTo("Abgeschlossene Station - AUFTRAG20260929001 - Station 2"); assertThat(mail.getSubject()).isEqualTo("Station abgeschlossen - AUFTRAG20260929001 - Station 2");
assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001", assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001",
"Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg", "Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg",
"Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:"); "Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:");
@@ -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);
}
}
Binary file not shown.