Compare commits
5
Commits
d7c0173493
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6044e4b1 | ||
|
|
8758c53e5b | ||
|
|
8044d82f04 | ||
|
|
b642f42954 | ||
|
|
7325c77739 |
No files matched your search
+101
-28
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:votianlt_app/models/cargo_item.dart';
|
||||
|
||||
import 'app_theme.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'l10n/localization_helpers.dart';
|
||||
import 'models/delivery_station.dart';
|
||||
import 'models/job.dart';
|
||||
import 'models/job_progress.dart';
|
||||
import 'services/database_service.dart';
|
||||
import 'task_view.dart';
|
||||
import 'widgets/offline_banner.dart';
|
||||
@@ -12,8 +14,10 @@ import 'widgets/offline_banner.dart';
|
||||
@visibleForTesting
|
||||
Color? deliveryStationCardBackgroundColor(
|
||||
DeliveryStation station,
|
||||
Map<String, bool> taskStatuses,
|
||||
) {
|
||||
Map<String, bool> taskStatuses, {
|
||||
bool isClosed = false,
|
||||
}) {
|
||||
if (isClosed) return AppColors.successSoft;
|
||||
if (station.tasks.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
@@ -36,6 +40,7 @@ class CargoItemsView extends StatefulWidget {
|
||||
class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
final DatabaseService _databaseService = DatabaseService();
|
||||
Map<String, bool> _taskStatuses = const {};
|
||||
Set<int> _completedStations = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -45,9 +50,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
|
||||
Future<void> _loadLocalTaskStatuses() async {
|
||||
final map = await _databaseService.loadAllTaskStatuses();
|
||||
final completedStations = await _databaseService.loadCompletedStationOrders(
|
||||
widget.job,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_taskStatuses = map;
|
||||
_completedStations = completedStations;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,8 +90,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: ListView(
|
||||
children: [
|
||||
// Job summary card
|
||||
Card(
|
||||
@@ -122,11 +130,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
color: Colors.green[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.job.pickupCity,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.job.pickupCity,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -142,13 +152,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.job.deliveryCitiesDisplay.isNotEmpty
|
||||
? widget.job.deliveryCitiesDisplay
|
||||
: widget.job.deliveryCity,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.job.deliveryCitiesDisplay.isNotEmpty
|
||||
? widget.job.deliveryCitiesDisplay
|
||||
: widget.job.deliveryCity,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -157,6 +169,30 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.job.cargoItems.isNotEmpty) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 24,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.cargo,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...widget.job.cargoItems.map(_buildCargoItemCard),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Delivery stations section header
|
||||
Row(
|
||||
children: [
|
||||
@@ -166,17 +202,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.deliveryStationsCount(_deliveryStations.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.deliveryStationsCount(_deliveryStations.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: _buildDeliveryStationsList()),
|
||||
_buildDeliveryStationsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -186,6 +224,39 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCargoItemCard(CargoItem item) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.description.trim().isNotEmpty
|
||||
? item.description
|
||||
: l10n.cargo,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('${l10n.quantity}: ${item.quantity}'),
|
||||
if (item.hasDimensions) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('${l10n.dimensions}: ${item.formattedDimensions}'),
|
||||
],
|
||||
if (item.weightKg.isFinite && item.weightKg > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('${l10n.weight}: ${item.formattedWeight}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<DeliveryStation> get _deliveryStations {
|
||||
if (widget.job.deliveryStations.isNotEmpty) {
|
||||
return widget.job.deliveryStations;
|
||||
@@ -238,12 +309,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: _deliveryStations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final station = _deliveryStations[index];
|
||||
return _buildDeliveryStationCard(station);
|
||||
},
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,6 +319,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
final backgroundColor = deliveryStationCardBackgroundColor(
|
||||
station,
|
||||
_taskStatuses,
|
||||
isClosed: JobProgress(
|
||||
widget.job,
|
||||
taskStatuses: _taskStatuses,
|
||||
localCompletedStations: _completedStations,
|
||||
).isStationClosed(station.stationOrder),
|
||||
);
|
||||
final title =
|
||||
station.displayName.isNotEmpty ? station.displayName : station.company;
|
||||
|
||||
@@ -120,10 +120,10 @@ class _ChatsViewState extends State<ChatsView> {
|
||||
title: Text(() {
|
||||
if (isJobChat) {
|
||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||
return 'Job $jobNumber';
|
||||
return 'Auftrag $jobNumber';
|
||||
}
|
||||
if (showJobId) {
|
||||
return 'Job $jobId';
|
||||
return 'Auftrag $jobId';
|
||||
}
|
||||
}
|
||||
return localizedChatTitle(context, chat);
|
||||
@@ -154,7 +154,7 @@ class _ChatsViewState extends State<ChatsView> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isJobChat ? 'JOB' : 'ALLG',
|
||||
isJobChat ? 'AUFTRAG' : 'ALLG',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
+56
-44
@@ -8,6 +8,7 @@ import 'services/dart_mq.dart';
|
||||
import 'services/chat_service.dart';
|
||||
import 'models/delivery_station.dart';
|
||||
import 'models/job.dart';
|
||||
import 'models/job_progress.dart';
|
||||
import 'models/task.dart';
|
||||
import 'models/tasks/confirmation_task.dart';
|
||||
import 'models/tasks/photo_task.dart';
|
||||
@@ -23,6 +24,13 @@ import 'services/database_service.dart';
|
||||
import 'navigation_observer.dart';
|
||||
import 'routing_view.dart';
|
||||
|
||||
@visibleForTesting
|
||||
Color? jobCardBackgroundColor(JobProgress progress) {
|
||||
if (progress.isCompleted) return AppColors.successSoft;
|
||||
if (progress.hasProgress) return AppColors.warningSoft;
|
||||
return null;
|
||||
}
|
||||
|
||||
class JobsView extends StatefulWidget {
|
||||
const JobsView({super.key});
|
||||
|
||||
@@ -63,6 +71,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
bool _isLoggingOut = false;
|
||||
final DatabaseService _databaseService = DatabaseService();
|
||||
Map<String, bool> _taskStatuses = const {};
|
||||
Map<String, Set<int>> _completedStations = const {};
|
||||
Map<String, bool> _jobSeen = const {};
|
||||
Map<String, double> _jobSwipeOffsets = const {};
|
||||
String? _openJobId;
|
||||
@@ -145,7 +154,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
if (mounted) {
|
||||
final message =
|
||||
jobNumber != null
|
||||
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||
? 'Auftrag $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||
: AppLocalizations.of(context).jobRemoved;
|
||||
_showSnack(message, backgroundColor: AppColors.warning);
|
||||
}
|
||||
@@ -479,9 +488,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
|
||||
Future<void> _loadLocalTaskStatuses() async {
|
||||
final map = await _databaseService.loadAllTaskStatuses();
|
||||
final completedStations = <String, Set<int>>{};
|
||||
for (final job in _appState.assignedJobs) {
|
||||
completedStations[job.id] = await _databaseService
|
||||
.loadCompletedStationOrders(job);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_taskStatuses = map;
|
||||
_completedStations = completedStations;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -950,25 +965,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
statusColor = AppColors.textMuted;
|
||||
}
|
||||
|
||||
// Determine card background color based on task completion
|
||||
final progress = JobProgress(
|
||||
job,
|
||||
taskStatuses: _taskStatuses,
|
||||
localCompletedStations: _completedStations[job.id] ?? const {},
|
||||
);
|
||||
final totalTasks = job.tasks.length;
|
||||
int completedTasks = 0;
|
||||
for (final t in job.tasks) {
|
||||
final isCompleted = _taskStatuses[t.id] ?? t.completed;
|
||||
if (isCompleted) completedTasks++;
|
||||
}
|
||||
|
||||
// Check if all tasks are completed (job is done)
|
||||
final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks;
|
||||
|
||||
Color? cardBg;
|
||||
if (totalTasks == 0 || completedTasks == 0) {
|
||||
cardBg = null; // unchanged (default)
|
||||
} else if (completedTasks > 0 && completedTasks < totalTasks) {
|
||||
cardBg = AppColors.warningSoft;
|
||||
} else if (completedTasks == totalTasks) {
|
||||
cardBg = AppColors.successSoft;
|
||||
}
|
||||
final completedTasks = progress.completedTaskCount;
|
||||
final isJobCompleted = progress.isCompleted;
|
||||
final cardBg = jobCardBackgroundColor(progress);
|
||||
// Build robust display strings with fallbacks
|
||||
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
||||
final pickupDisplayName =
|
||||
@@ -1211,7 +1216,17 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Progress bar for tasks
|
||||
if (isJobCompleted) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).jobCompleted,
|
||||
style: const TextStyle(
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
// Progress bar for tasks (optional tasks retain their actual state).
|
||||
if (totalTasks > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@@ -1235,7 +1250,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
minHeight: 8,
|
||||
backgroundColor: AppColors.border,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
completedTasks >= totalTasks
|
||||
isJobCompleted ||
|
||||
completedTasks >= totalTasks
|
||||
? AppColors.success
|
||||
: (completedTasks > 0
|
||||
? AppColors.warning
|
||||
@@ -1406,7 +1422,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Dates and Price
|
||||
// Creation date
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1428,27 +1444,6 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (job.price > 0) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green[200]!),
|
||||
),
|
||||
child: Text(
|
||||
'${job.price.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1468,6 +1463,14 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
}
|
||||
|
||||
void _toggleTaskCompletion(Job job, int taskIndex) {
|
||||
final progress = JobProgress(
|
||||
job,
|
||||
taskStatuses: _taskStatuses,
|
||||
localCompletedStations: _completedStations[job.id] ?? const {},
|
||||
);
|
||||
if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) {
|
||||
return;
|
||||
}
|
||||
// Create a new task with toggled completion status
|
||||
final updatedTask = job.tasks[taskIndex].copyWith(
|
||||
completed: !job.tasks[taskIndex].completed,
|
||||
@@ -1509,6 +1512,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
id: job.id,
|
||||
jobNumber: job.jobNumber,
|
||||
status: job.status,
|
||||
completedStationOrders: job.completedStationOrders,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
createdBy: job.createdBy,
|
||||
@@ -1711,7 +1715,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
||||
const SizedBox(height: 8),
|
||||
...job.tasks.asMap().entries.map(
|
||||
(entry) => GestureDetector(
|
||||
onTap: () => _toggleTaskCompletion(job, entry.key),
|
||||
onTap:
|
||||
JobProgress(
|
||||
job,
|
||||
taskStatuses: _taskStatuses,
|
||||
localCompletedStations:
|
||||
_completedStations[job.id] ?? const {},
|
||||
).isStationClosed(entry.value.stationOrder ?? 0)
|
||||
? null
|
||||
: () => _toggleTaskCompletion(job, entry.key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
||||
@@ -52,7 +52,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
// ==================== NAVIGATION ====================
|
||||
@override
|
||||
String get jobs => 'Jobs';
|
||||
String get jobs => 'Aufträge';
|
||||
|
||||
@override
|
||||
String get availableJobs => 'Auftragsliste';
|
||||
@@ -155,10 +155,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
// ==================== JOBS ====================
|
||||
@override
|
||||
String get noJobsAssigned => 'Keine Jobs zugewiesen';
|
||||
String get noJobsAssigned => 'Keine Aufträge zugewiesen';
|
||||
|
||||
@override
|
||||
String get noJobsMessage => 'Ihre zugewiesenen Jobs werden hier angezeigt.';
|
||||
String get noJobsMessage =>
|
||||
'Ihre zugewiesenen Aufträge werden hier angezeigt.';
|
||||
|
||||
@override
|
||||
String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren';
|
||||
@@ -206,13 +207,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get dimensions => 'Abmessungen';
|
||||
|
||||
@override
|
||||
String get jobDeleted => 'Job gelöscht';
|
||||
String get jobDeleted => 'Auftrag gelöscht';
|
||||
|
||||
@override
|
||||
String get jobDeleteError => 'Fehler beim Löschen des Jobs';
|
||||
String get jobDeleteError => 'Fehler beim Löschen des Auftrags';
|
||||
|
||||
@override
|
||||
String get jobCompleted => 'Job abgeschlossen';
|
||||
String get jobCompleted => 'Auftrag abgeschlossen';
|
||||
|
||||
@override
|
||||
String get from => 'Von';
|
||||
@@ -221,10 +222,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get to => 'nach';
|
||||
|
||||
@override
|
||||
String get jobsUpdated => 'Jobs aktualisiert';
|
||||
String get jobsUpdated => 'Aufträge aktualisiert';
|
||||
|
||||
@override
|
||||
String get connectionRestored => 'Verbindung wiederhergestellt. Lade Jobs...';
|
||||
String get connectionRestored =>
|
||||
'Verbindung wiederhergestellt. Lade Aufträge...';
|
||||
|
||||
@override
|
||||
String get connectionLost => 'Verbindung verloren. Offline.';
|
||||
@@ -233,13 +235,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get offline => 'Offline';
|
||||
|
||||
@override
|
||||
String get deleteJob => 'Job löschen';
|
||||
String get deleteJob => 'Auftrag löschen';
|
||||
|
||||
@override
|
||||
String get jobRemoved => 'wurde entfernt';
|
||||
|
||||
@override
|
||||
String get newJobReceived => 'Neuer Job erhalten';
|
||||
String get newJobReceived => 'Neuer Auftrag erhalten';
|
||||
|
||||
@override
|
||||
String get jobDetails => 'Auftragsdetails';
|
||||
@@ -258,7 +260,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get noDeliveryStationsMessage =>
|
||||
'Dieser Job enthält aktuell keine Lieferstationen.';
|
||||
'Dieser Auftrag enthält aktuell keine Lieferstationen.';
|
||||
|
||||
@override
|
||||
String get phone => 'Telefon';
|
||||
@@ -277,7 +279,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get noTasks => 'Keine Aufgaben';
|
||||
|
||||
@override
|
||||
String get noTasksMessage => 'Für diesen Job sind keine Aufgaben definiert.';
|
||||
String get noTasksMessage =>
|
||||
'Für diesen Auftrag sind keine Aufgaben definiert.';
|
||||
|
||||
@override
|
||||
String get taskOrder => 'Reihenfolge';
|
||||
@@ -498,13 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get imageSendError => 'Bild konnte nicht gesendet werden.';
|
||||
|
||||
@override
|
||||
String get chatTypeJob => 'Job-spezifisch';
|
||||
String get chatTypeJob => 'Auftragsbezogen';
|
||||
|
||||
@override
|
||||
String get chatTypeGeneral => 'Allgemein';
|
||||
|
||||
@override
|
||||
String get jobNumber => 'Job-Nummer';
|
||||
String get jobNumber => 'Auftragsnummer';
|
||||
|
||||
@override
|
||||
String get messages => 'Nachrichten';
|
||||
@@ -558,7 +561,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get noCargoItemsMessage =>
|
||||
'Für diesen Job sind keine Frachtgüter definiert.';
|
||||
'Für diesen Auftrag sind keine Frachtgüter definiert.';
|
||||
|
||||
@override
|
||||
String get article => 'Artikel';
|
||||
|
||||
@@ -46,5 +46,12 @@ String localizedChatTitle(BuildContext context, Chat chat) {
|
||||
if (chat.type == ChatType.general) {
|
||||
return AppLocalizations.of(context).generalMessages;
|
||||
}
|
||||
// Older locally stored conversations still use the previous title prefix.
|
||||
if (chat.title.startsWith('Job ')) {
|
||||
return 'Auftrag ${chat.title.substring(4)}';
|
||||
}
|
||||
if (chat.title == 'Job-Chat') {
|
||||
return 'Auftrags-Chat';
|
||||
}
|
||||
return localizeKnownText(context, chat.title);
|
||||
}
|
||||
@@ -87,9 +87,18 @@ class CargoItem {
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
bool get hasDimensions =>
|
||||
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
|
||||
|
||||
/// Get formatted dimensions string for display
|
||||
String get formattedDimensions {
|
||||
return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm';
|
||||
final dimensions = [lengthCm, widthCm, heightCm].map((value) {
|
||||
if (!value.isFinite || value <= 0) return '–';
|
||||
return value == value.truncateToDouble()
|
||||
? value.toStringAsFixed(0)
|
||||
: value.toString();
|
||||
});
|
||||
return '${dimensions.join(' × ')} cm';
|
||||
}
|
||||
|
||||
/// Get formatted weight string for display
|
||||
|
||||
@@ -98,7 +98,7 @@ class Chat {
|
||||
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
||||
return Chat(
|
||||
id: id,
|
||||
title: 'Job $jobNumber',
|
||||
title: 'Auftrag $jobNumber',
|
||||
receiver: receiver,
|
||||
type: ChatType.jobSpecific,
|
||||
jobId: jobId,
|
||||
|
||||
@@ -52,7 +52,7 @@ class DeliveryStation {
|
||||
final tasks =
|
||||
(json['tasks'] as List? ?? const []).map((rawTask) {
|
||||
final taskJson = Map<String, dynamic>.from(rawTask as Map);
|
||||
taskJson['stationOrder'] ??= stationOrder;
|
||||
taskJson['stationOrder'] = stationOrder;
|
||||
return Task.fromJson(taskJson);
|
||||
}).toList()
|
||||
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@ class Job {
|
||||
final String id; // Will store the timestamp as string
|
||||
final String jobNumber;
|
||||
final String status;
|
||||
final List<int> completedStationOrders;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String createdBy;
|
||||
@@ -61,6 +62,7 @@ class Job {
|
||||
required this.id,
|
||||
required this.jobNumber,
|
||||
required this.status,
|
||||
this.completedStationOrders = const [],
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.createdBy,
|
||||
@@ -109,7 +111,7 @@ class Job {
|
||||
this.assignedTo,
|
||||
this.location,
|
||||
this.additionalData,
|
||||
}) : title = title ?? 'Job $jobNumber',
|
||||
}) : title = title ?? 'Auftrag $jobNumber',
|
||||
description =
|
||||
description ?? 'Transport von $pickupCity nach $deliveryCity';
|
||||
|
||||
@@ -332,6 +334,12 @@ class Job {
|
||||
id: idValue,
|
||||
jobNumber: jobJson['jobNumber']?.toString() ?? '',
|
||||
status: jobJson['status']?.toString() ?? 'UNKNOWN',
|
||||
completedStationOrders:
|
||||
(jobJson['completedStationOrders'] as List? ?? const [])
|
||||
.map((value) => int.tryParse(value.toString()))
|
||||
.whereType<int>()
|
||||
.toSet()
|
||||
.toList(),
|
||||
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
|
||||
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
|
||||
createdBy: jobJson['createdBy']?.toString() ?? '',
|
||||
@@ -391,6 +399,7 @@ class Job {
|
||||
id: id,
|
||||
jobNumber: t(jobNumber),
|
||||
status: t(status),
|
||||
completedStationOrders: List.of(completedStationOrders),
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
createdBy: t(createdBy),
|
||||
@@ -446,6 +455,7 @@ class Job {
|
||||
'id': id,
|
||||
'jobNumber': jobNumber,
|
||||
'status': status,
|
||||
'completedStationOrders': completedStationOrders,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'createdBy': createdBy,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -350,12 +350,12 @@ class ChatService {
|
||||
|
||||
String _buildJobTitle(String? jobNumber, String? jobId) {
|
||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||
return 'Job $jobNumber';
|
||||
return 'Auftrag $jobNumber';
|
||||
}
|
||||
if (jobId != null && jobId.length >= 6) {
|
||||
return 'Job ${jobId.substring(0, 6).toUpperCase()}';
|
||||
return 'Auftrag ${jobId.substring(0, 6).toUpperCase()}';
|
||||
}
|
||||
return 'Job-Chat';
|
||||
return 'Auftrags-Chat';
|
||||
}
|
||||
|
||||
String? _determineCounterpart(
|
||||
|
||||
@@ -368,6 +368,23 @@ class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge server confirmations and locally persisted offline confirmations.
|
||||
Future<Set<int>> loadCompletedStationOrders(Job job) async {
|
||||
final orders =
|
||||
job.deliveryStations.isEmpty
|
||||
? {0}
|
||||
: job.deliveryStations
|
||||
.map((station) => station.stationOrder)
|
||||
.toSet();
|
||||
final completed = <int>{...job.completedStationOrders};
|
||||
for (final order in orders) {
|
||||
if (await loadKeyValue('station_completed:${job.id}:$order') == '1') {
|
||||
completed.add(order);
|
||||
}
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
/// Load all task completion statuses
|
||||
Future<Map<String, bool>> loadAllTaskStatuses() async {
|
||||
try {
|
||||
|
||||
@@ -21,9 +21,9 @@ class NotificationService {
|
||||
'Benachrichtigungen bei neuen Chat-Nachrichten';
|
||||
|
||||
static const String _jobChannelId = 'new_jobs';
|
||||
static const String _jobChannelName = 'Neue Jobs';
|
||||
static const String _jobChannelName = 'Neue Aufträge';
|
||||
static const String _jobChannelDescription =
|
||||
'Benachrichtigungen bei neuen Job-Zuweisungen';
|
||||
'Benachrichtigungen bei neuen Auftragszuweisungen';
|
||||
|
||||
int _nextId = 0;
|
||||
|
||||
@@ -49,12 +49,15 @@ class NotificationService {
|
||||
|
||||
await _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
|
||||
_initialized = true;
|
||||
developer.log('NotificationService initialized',
|
||||
name: 'NotificationService');
|
||||
developer.log(
|
||||
'NotificationService initialized',
|
||||
name: 'NotificationService',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChatNotification({
|
||||
@@ -87,8 +90,13 @@ class NotificationService {
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(_nextId++, title, body, details,
|
||||
payload: 'chat:$conversationKey');
|
||||
await _plugin.show(
|
||||
_nextId++,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: 'chat:$conversationKey',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showJobNotification({
|
||||
|
||||
@@ -440,11 +440,11 @@ class WebSocketService {
|
||||
if (currentJobCount > 0 && jobs.length > currentJobCount) {
|
||||
final newCount = jobs.length - currentJobCount;
|
||||
NotificationService().showJobNotification(
|
||||
title: 'Neue Jobs',
|
||||
title: 'Neue Aufträge',
|
||||
body:
|
||||
newCount == 1
|
||||
? 'Sie haben einen neuen Job erhalten.'
|
||||
: 'Sie haben $newCount neue Jobs erhalten.',
|
||||
? 'Sie haben einen neuen Auftrag erhalten.'
|
||||
: 'Sie haben $newCount neue Aufträge erhalten.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -597,11 +597,11 @@ class WebSocketService {
|
||||
|
||||
// Show notification with sound for new job
|
||||
NotificationService().showJobNotification(
|
||||
title: 'Neuer Job',
|
||||
title: 'Neuer Auftrag',
|
||||
body:
|
||||
jobNumber != null && jobNumber.isNotEmpty
|
||||
? 'Job $jobNumber wurde Ihnen zugewiesen.'
|
||||
: 'Sie haben einen neuen Job erhalten.',
|
||||
? 'Auftrag $jobNumber wurde Ihnen zugewiesen.'
|
||||
: 'Sie haben einen neuen Auftrag erhalten.',
|
||||
);
|
||||
|
||||
// Publish event via DartMQ for UI to handle
|
||||
@@ -642,8 +642,8 @@ class WebSocketService {
|
||||
final jobNumber = message.jobNumber ?? '';
|
||||
notificationTitle =
|
||||
jobNumber.isNotEmpty
|
||||
? 'Nachricht zu Job $jobNumber'
|
||||
: 'Neue Job-Nachricht';
|
||||
? 'Nachricht zu Auftrag $jobNumber'
|
||||
: 'Neue Auftragsnachricht';
|
||||
} else {
|
||||
notificationTitle = 'Neue Nachricht';
|
||||
}
|
||||
@@ -1216,6 +1216,7 @@ class WebSocketService {
|
||||
name: 'WebSocketService',
|
||||
);
|
||||
developer.log('Stack: $st', name: 'WebSocketService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+115
-30
@@ -6,9 +6,11 @@ import 'package:image/image.dart' as img;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'app_theme.dart';
|
||||
import 'app_state.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'l10n/localization_helpers.dart';
|
||||
import 'models/job.dart';
|
||||
import 'models/job_progress.dart';
|
||||
import 'models/task.dart';
|
||||
import 'models/tasks/confirmation_task.dart';
|
||||
import 'models/tasks/photo_task.dart';
|
||||
@@ -42,29 +44,86 @@ class TaskView extends StatefulWidget {
|
||||
class _TaskViewState extends State<TaskView> {
|
||||
final Set<String> _completedTasks = {};
|
||||
final DatabaseService _databaseService = DatabaseService();
|
||||
late Job _job;
|
||||
Set<int> _localCompletedStations = {};
|
||||
StreamSubscription<void>? _jobsUpdatedSubscription;
|
||||
bool _statusesLoaded = false;
|
||||
bool _stationCompleted = false;
|
||||
bool _isCompletingStation = false;
|
||||
// Store SVG representations of signatures per task for later use
|
||||
final Map<String, String> _signatureSvgByTask = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_job = widget.job;
|
||||
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
|
||||
(_) => _loadTaskStatuses(),
|
||||
);
|
||||
_loadTaskStatuses();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TaskView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.job, widget.job) ||
|
||||
oldWidget.stationOrder != widget.stationOrder) {
|
||||
_job = widget.job;
|
||||
_statusesLoaded = false;
|
||||
_loadTaskStatuses();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_jobsUpdatedSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
JobProgress get _progress => JobProgress(
|
||||
_job,
|
||||
taskStatuses: {for (final id in _completedTasks) id: true},
|
||||
localCompletedStations: _localCompletedStations,
|
||||
);
|
||||
|
||||
bool _canCompleteTask(Task task) =>
|
||||
mounted &&
|
||||
_statusesLoaded &&
|
||||
!_isCompletingStation &&
|
||||
!_stationCompleted &&
|
||||
_progress.canCompleteTask(task);
|
||||
|
||||
List<Task> get _visibleTasks {
|
||||
final stationOrder = widget.stationOrder;
|
||||
if (stationOrder == null) {
|
||||
return widget.job.tasks;
|
||||
return _job.tasks;
|
||||
}
|
||||
return widget.job.tasks
|
||||
return _job.tasks
|
||||
.where((task) => task.stationOrder == stationOrder)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String get _stationCompletionKey =>
|
||||
'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
|
||||
|
||||
/// Load task completion and skipped statuses from database and merge with JSON task states
|
||||
Future<void> _loadTaskStatuses() async {
|
||||
var latestJob = _job;
|
||||
for (final savedJob in await _databaseService.loadJobs()) {
|
||||
if (savedJob.id == _job.id &&
|
||||
!savedJob.updatedAt.isBefore(latestJob.updatedAt)) {
|
||||
latestJob = savedJob;
|
||||
}
|
||||
}
|
||||
final statuses = await _databaseService.loadAllTaskStatuses();
|
||||
final completedStations = await _databaseService.loadCompletedStationOrders(
|
||||
latestJob,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_job = latestJob;
|
||||
_localCompletedStations = completedStations;
|
||||
_statusesLoaded = true;
|
||||
_completedTasks.clear();
|
||||
// 1) Add all completed from DB
|
||||
for (final entry in statuses.entries) {
|
||||
@@ -73,11 +132,12 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
}
|
||||
// 2) Merge: also mark tasks completed if the job JSON already had them completed
|
||||
for (final t in widget.job.tasks) {
|
||||
for (final t in _job.tasks) {
|
||||
if (t.completed) {
|
||||
_completedTasks.add(t.id);
|
||||
}
|
||||
}
|
||||
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,7 +148,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
title: Text(
|
||||
widget.stationTitle?.isNotEmpty == true
|
||||
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
||||
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
|
||||
: '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
@@ -148,9 +208,11 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
|
||||
bool get _canCompleteStation {
|
||||
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
|
||||
return false;
|
||||
}
|
||||
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
||||
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
|
||||
// immer aktiv.
|
||||
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
|
||||
for (final t in _visibleTasks) {
|
||||
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||
return false;
|
||||
@@ -175,7 +237,9 @@ class _TaskViewState extends State<TaskView> {
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: enabled ? _onCompleteStationPressed : null,
|
||||
icon: const Icon(Icons.flag),
|
||||
label: const Text('Station abschließen'),
|
||||
label: Text(
|
||||
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
@@ -184,6 +248,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
|
||||
void _onCompleteStationPressed() {
|
||||
if (!_canCompleteStation) return;
|
||||
if (_hasIncompleteOptionalTasks) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -214,21 +279,31 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
}
|
||||
|
||||
void _sendStationCompleted() {
|
||||
Future<void> _sendStationCompleted() async {
|
||||
if (!_canCompleteStation) return;
|
||||
setState(() => _isCompletingStation = true);
|
||||
final stationOrder = widget.stationOrder ?? 0;
|
||||
try {
|
||||
StompService().sendStationCompleted(
|
||||
jobId: widget.job.id,
|
||||
jobNumber: widget.job.jobNumber,
|
||||
await StompService().sendStationCompleted(
|
||||
jobId: _job.id,
|
||||
jobNumber: _job.jobNumber,
|
||||
stationOrder: stationOrder,
|
||||
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Station abgeschlossen')),
|
||||
);
|
||||
_stationCompleted = true;
|
||||
_localCompletedStations.add(stationOrder);
|
||||
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Station abgeschlossen')));
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
developer.log('Error sending station completion: $e', name: 'TaskView');
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isCompletingStation = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +340,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
final task = _visibleTasks[index];
|
||||
final isCompleted = _completedTasks.contains(task.id);
|
||||
final canBeCompletedNow =
|
||||
!isCompleted && _arePreviousTasksCompleted(index);
|
||||
_canCompleteTask(task) && _arePreviousTasksCompleted(index);
|
||||
|
||||
// Hintergrundfarbe je nach Status:
|
||||
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
|
||||
@@ -372,18 +447,18 @@ class _TaskViewState extends State<TaskView> {
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningSoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningSoft,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Optional',
|
||||
style: TextStyle(
|
||||
@@ -405,6 +480,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
|
||||
void _showTaskCompletionDialog(Task task, int taskIndex) {
|
||||
if (!_canCompleteTask(task)) return;
|
||||
switch (task) {
|
||||
case ConfirmationTask():
|
||||
_showConfirmationDialog(task, taskIndex);
|
||||
@@ -518,10 +594,12 @@ class _TaskViewState extends State<TaskView> {
|
||||
(context) => PhotoCaptureScreen(
|
||||
task: task,
|
||||
onPhotosCompleted: (List<Uint8List> photoData) async {
|
||||
if (!_canCompleteTask(task)) return;
|
||||
// Compress + encode photos for network send (limit payload)
|
||||
final List<String> base64List = await _compressAndEncodePhotos(
|
||||
photoData,
|
||||
);
|
||||
if (!_canCompleteTask(task)) return;
|
||||
final bool truncated = base64List.length < photoData.length;
|
||||
|
||||
// Try to persist full-quality (encoded) photos to DB for offline/backup
|
||||
@@ -623,6 +701,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
(context) => SignatureCaptureScreen(
|
||||
task: task,
|
||||
onSignatureCompleted: (String svg, String note) async {
|
||||
if (!_canCompleteTask(task)) return;
|
||||
try {
|
||||
// Persist SVG only (no PNG)
|
||||
await _databaseService.saveTaskSignature(task.id, svg);
|
||||
@@ -634,6 +713,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
);
|
||||
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
||||
}
|
||||
if (!_canCompleteTask(task)) return;
|
||||
// Store SVG for later use in this TaskView session
|
||||
setState(() {
|
||||
_signatureSvgByTask[task.id] = svg;
|
||||
@@ -666,6 +746,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
(context) => BarcodeCaptureScreen(
|
||||
task: task,
|
||||
onBarcodesCompleted: (List<String> barcodes) async {
|
||||
if (!_canCompleteTask(task)) return;
|
||||
try {
|
||||
// Save barcodes to database for later use
|
||||
await _databaseService.saveTaskBarcodes(task.id, barcodes);
|
||||
@@ -734,6 +815,8 @@ class _TaskViewState extends State<TaskView> {
|
||||
String? taskType,
|
||||
Map<String, dynamic>? extraData,
|
||||
}) {
|
||||
final tasks = _visibleTasks.where((task) => task.id == taskId);
|
||||
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
|
||||
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
||||
setState(() {
|
||||
_completedTasks.add(taskId);
|
||||
@@ -756,7 +839,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
||||
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
if (mounted && _canCompleteStation) {
|
||||
_showLastMandatoryCompletedDialog();
|
||||
}
|
||||
});
|
||||
@@ -899,7 +982,9 @@ class _TaskViewState extends State<TaskView> {
|
||||
? localizeKnownText(context, task.description!)
|
||||
: null;
|
||||
final String? signatureNote =
|
||||
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
|
||||
(task is SignatureTask &&
|
||||
task.note != null &&
|
||||
task.note!.trim().isNotEmpty)
|
||||
? task.note!.trim()
|
||||
: null;
|
||||
|
||||
@@ -974,7 +1059,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getRemark() => widget.job.remark;
|
||||
String _getRemark() => _job.remark;
|
||||
|
||||
String? _getTaskStationLabel(Task task) {
|
||||
if (widget.stationOrder != null) {
|
||||
@@ -985,7 +1070,7 @@ class _TaskViewState extends State<TaskView> {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final station in widget.job.deliveryStations) {
|
||||
for (final station in _job.deliveryStations) {
|
||||
if (station.stationOrder == stationOrder) {
|
||||
final suffix =
|
||||
station.displayName.isNotEmpty ? station.displayName : station.city;
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.9.22+922
|
||||
version: 0.9.25+925
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -64,7 +64,8 @@ cd "${SCRIPT_DIR}"
|
||||
|
||||
echo "Verwende Release-Version ${VERSION}."
|
||||
echo "Baue Production-JAR für Version ${VERSION} ..."
|
||||
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests -Drevision="${VERSION}" package)
|
||||
# Klassen aus vorherigen Maven-/IDE-Builds vor dem Vaadin-Klassenscan entfernen.
|
||||
(cd "${BACKEND_DIR}" && ./mvnw -e -Pproduction -DskipTests -Drevision="${VERSION}" clean package)
|
||||
|
||||
JAR_FILE_REL="target/votianlt-${VERSION}.jar"
|
||||
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.9.22</revision>
|
||||
<revision>0.9.26</revision>
|
||||
<java.version>21</java.version>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || {
|
||||
initialized: false
|
||||
};
|
||||
|
||||
window.initProfileInvoiceGenerator = function() {
|
||||
// A tab can still be hidden when Vaadin sends its selected-change response.
|
||||
// Wait for its actual layout instead of relying on fixed delays and a server callback.
|
||||
window.showProfileInvoiceTemplate = function(container, view, templateData, masterdata, vatRate) {
|
||||
if (window.pendingProfileInvoiceLoad) {
|
||||
window.pendingProfileInvoiceLoad.disconnect();
|
||||
window.pendingProfileInvoiceLoad = null;
|
||||
}
|
||||
function loadWhenVisible() {
|
||||
var rect = container.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return false;
|
||||
window.invoiceGeneratorViewProfile = view;
|
||||
window.profileInvoiceServiceData = null;
|
||||
window.profileInvoiceVatRate = vatRate;
|
||||
window.masterdataValues = masterdata;
|
||||
window.initProfileInvoiceGenerator(container);
|
||||
window.loadProfileTemplate(templateData);
|
||||
return true;
|
||||
}
|
||||
if (!loadWhenVisible()) {
|
||||
var observer = new ResizeObserver(function() {
|
||||
if (window.pendingProfileInvoiceLoad !== observer) return;
|
||||
if (loadWhenVisible()) {
|
||||
observer.disconnect();
|
||||
if (window.pendingProfileInvoiceLoad === observer) window.pendingProfileInvoiceLoad = null;
|
||||
}
|
||||
});
|
||||
window.pendingProfileInvoiceLoad = observer;
|
||||
observer.observe(container);
|
||||
}
|
||||
};
|
||||
|
||||
window.initProfileInvoiceGenerator = function(container) {
|
||||
var containerId = 'invoice-canvas-container-profile';
|
||||
var container = document.getElementById(containerId);
|
||||
container = container || document.getElementById(containerId);
|
||||
|
||||
if (!container) {
|
||||
console.error('Canvas container not found:', containerId);
|
||||
@@ -233,28 +264,9 @@ window.initProfileInvoiceGenerator = function() {
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
imageCache[el.imageData] = img;
|
||||
// Calculate dimensions to maintain aspect ratio
|
||||
var imgAspect = img.width / img.height;
|
||||
var boxAspect = w / h;
|
||||
var drawW, drawH, drawX, drawY;
|
||||
|
||||
if (imgAspect > boxAspect) {
|
||||
drawW = w;
|
||||
drawH = w / imgAspect;
|
||||
drawX = x;
|
||||
drawY = y + (h - drawH) / 2;
|
||||
} else {
|
||||
drawW = h * imgAspect;
|
||||
drawH = h;
|
||||
drawX = x + (w - drawW) / 2;
|
||||
drawY = y;
|
||||
}
|
||||
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
||||
// Redraw selection if this element is selected
|
||||
if (selectedElement && selectedElement.id === el.id) {
|
||||
drawSelection(el);
|
||||
}
|
||||
// Redraw in element order: a letterhead loaded asynchronously must
|
||||
// stay behind the invoice text, including after resize or removal.
|
||||
draw();
|
||||
};
|
||||
img.src = el.imageData;
|
||||
}
|
||||
@@ -1322,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() {
|
||||
} else if (el.variable.startsWith('masterdata.')) {
|
||||
// Masterdata variables - use actual value from masterdata or placeholder
|
||||
var value = masterdata[el.variable];
|
||||
if (value && value.trim() !== '') {
|
||||
if (typeof value === 'string') {
|
||||
el.text = value;
|
||||
} else {
|
||||
el.text = '[' + el.variable.replace('masterdata.', '') + ']';
|
||||
|
||||
@@ -163,13 +163,13 @@ public class AiStatisticsService {
|
||||
- Untersuche, erwähne oder vermute niemals Daten anderer Benutzer
|
||||
|
||||
WICHTIG - ANTWORTSTIL:
|
||||
- Der Benutzer fragt nach einer Liste von Jobs/Aufträgen
|
||||
- Der Benutzer fragt nach einer Liste von Aufträgen
|
||||
- Gib ausschließlich ein JSON-Objekt im geforderten Format aus
|
||||
- Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus
|
||||
- Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen
|
||||
- Vermeide Formulierungen wie "Sie fragen", "Du fragst", "Die Frage ist", "Gewünscht ist" oder "Der Benutzer möchte wissen"
|
||||
- Im Feld "answer" beginnt die Antwort direkt mit dem Ergebnis
|
||||
- Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Jobnummern
|
||||
- Nenne nur das Ergebnis der Suche, z.B. die Anzahl der Treffer oder die relevanten Auftragsnummern
|
||||
- Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage
|
||||
- Maximal 1-2 Sätze
|
||||
- Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen
|
||||
@@ -803,27 +803,27 @@ public class AiStatisticsService {
|
||||
List<Job> jobs;
|
||||
if (customerFilter != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers für %s mit Status %s:**\n\n",
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers für %s mit Status %s:**\n\n",
|
||||
customerFilter, statusFilter.getDisplayName()));
|
||||
} else if (customerFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers für %s:**\n\n", customerFilter));
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers für %s:**\n\n", customerFilter));
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
||||
context.append(String.format("**Jobs des angemeldeten Benutzers mit Status %s:**\n\n",
|
||||
context.append(String.format("**Aufträge des angemeldeten Benutzers mit Status %s:**\n\n",
|
||||
statusFilter.getDisplayName()));
|
||||
} else {
|
||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
||||
context.append("**Aktuelle Jobs des angemeldeten Benutzers:**\n\n");
|
||||
context.append("**Aktuelle Aufträge des angemeldeten Benutzers:**\n\n");
|
||||
}
|
||||
|
||||
context.append(String.format("Gefunden: %d Jobs\n\n", jobs.size()));
|
||||
context.append(String.format("Gefunden: %d Aufträge\n\n", jobs.size()));
|
||||
|
||||
// Show job summaries (max 10)
|
||||
int shown = 0;
|
||||
for (Job job : jobs) {
|
||||
if (shown >= 10) {
|
||||
context.append(String.format("... und %d weitere Jobs\n", jobs.size() - 10));
|
||||
context.append(String.format("... und %d weitere Aufträge\n", jobs.size() - 10));
|
||||
break;
|
||||
}
|
||||
context.append(
|
||||
@@ -943,7 +943,7 @@ public class AiStatisticsService {
|
||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||
job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt"))
|
||||
.toList();
|
||||
return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows);
|
||||
return buildHtmlTable(List.of("Auftragsnummer", "Kunde", "Status"), rows);
|
||||
}
|
||||
|
||||
private String buildStatusTableHtml(String currentUserId, String customerFilter) {
|
||||
@@ -1209,17 +1209,17 @@ public class AiStatisticsService {
|
||||
List<Job> jobs;
|
||||
if (customer != null && statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter);
|
||||
yield String.format("%d Jobs für %s mit Status %s.", jobs.size(), customer,
|
||||
yield String.format("%d Aufträge für %s mit Status %s.", jobs.size(), customer,
|
||||
statusFilter.getDisplayName());
|
||||
} else if (customer != null) {
|
||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer);
|
||||
yield String.format("%d Jobs für %s.", jobs.size(), customer);
|
||||
yield String.format("%d Aufträge für %s.", jobs.size(), customer);
|
||||
} else if (statusFilter != null) {
|
||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
||||
yield String.format("%d Jobs mit Status %s.", jobs.size(), statusFilter.getDisplayName());
|
||||
yield String.format("%d Aufträge mit Status %s.", jobs.size(), statusFilter.getDisplayName());
|
||||
} else {
|
||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
||||
yield String.format("%d aktuelle Jobs.", jobs.size());
|
||||
yield String.format("%d aktuelle Aufträge.", jobs.size());
|
||||
}
|
||||
}
|
||||
case "status" -> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
||||
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.CargoItem;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||
@@ -31,10 +32,16 @@ import de.assecutor.votianlt.model.JobStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||
import org.springframework.data.mongodb.core.query.Criteria;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.bson.types.ObjectId;
|
||||
|
||||
@@ -53,6 +60,7 @@ public class MessageController {
|
||||
private final AppUserService appUserService;
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
|
||||
private final CargoItemRepository cargoItemRepository;
|
||||
|
||||
@@ -72,11 +80,12 @@ public class MessageController {
|
||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
||||
MessageService messageService, TaskAssignmentService taskAssignmentService) {
|
||||
MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) {
|
||||
this.messagingPublisher = messagingPublisher;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.appUserService = appUserService;
|
||||
this.jobRepository = jobRepository;
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
this.cargoItemRepository = cargoItemRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
this.photoRepository = photoRepository;
|
||||
@@ -178,30 +187,45 @@ public class MessageController {
|
||||
* go to generic processing.
|
||||
*/
|
||||
public void handleTaskCompleted(Map<String, Object> payload, String taskType) {
|
||||
handleTaskCompleted(payload, taskType, null);
|
||||
}
|
||||
|
||||
public void handleTaskCompleted(Map<String, Object> payload, String taskType, String appUserId) {
|
||||
Object taskId = payload != null ? payload.get("taskId") : null;
|
||||
if (taskId == null || !ObjectId.isValid(taskId.toString())) {
|
||||
return;
|
||||
}
|
||||
Optional<BaseTask> task = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
if (task.isEmpty() || taskAssignmentService.findJobForTask(task.get())
|
||||
.filter(job -> canCompleteTask(task.get(), job)).isEmpty()) {
|
||||
// Check before dispatch: photo/signature/barcode/comment handlers also
|
||||
// persist attachments before completing the task itself.
|
||||
return;
|
||||
}
|
||||
String key = taskType == null ? "" : taskType.trim().toUpperCase();
|
||||
|
||||
switch (key) {
|
||||
case "PHOTO" -> processPhotoTaskCompletion(payload);
|
||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload);
|
||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload);
|
||||
case "TODOLIST" -> processTodoListTaskCompletion(payload);
|
||||
case "BARCODE" -> processBarcodeTaskCompletion(payload);
|
||||
case "COMMENT" -> processCommentTaskCompletion(payload);
|
||||
case "PHOTO" -> processPhotoTaskCompletion(payload, appUserId);
|
||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload, appUserId);
|
||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload, appUserId);
|
||||
case "TODOLIST" -> processTodoListTaskCompletion(payload, appUserId);
|
||||
case "BARCODE" -> processBarcodeTaskCompletion(payload, appUserId);
|
||||
case "COMMENT" -> processCommentTaskCompletion(payload, appUserId);
|
||||
default -> log.error("[TASK] Unknown taskType: {}", taskType);
|
||||
}
|
||||
}
|
||||
|
||||
private void processConfirmationTaskCompletion(Map<String, Object> payload) {
|
||||
private void processConfirmationTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Bestätigung durchgeführt");
|
||||
completeTaskWithHistory(taskId, "Bestätigung durchgeführt", appUserId);
|
||||
}
|
||||
|
||||
private void processTodoListTaskCompletion(Map<String, Object> payload) {
|
||||
private void processTodoListTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt");
|
||||
completeTaskWithHistory(taskId, "Alle To-Do-Elemente abgehakt", appUserId);
|
||||
}
|
||||
|
||||
private void processBarcodeTaskCompletion(Map<String, Object> payload) {
|
||||
private void processBarcodeTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -220,7 +244,7 @@ public class MessageController {
|
||||
|
||||
if (!barcodes.isEmpty()) {
|
||||
for (String barcodeString : barcodes) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
|
||||
completedBy);
|
||||
barcodeRepository.save(barcodeEntry);
|
||||
@@ -238,13 +262,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Barcode completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processSignatureTaskCompletion(Map<String, Object> payload) {
|
||||
private void processSignatureTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -261,7 +285,7 @@ public class MessageController {
|
||||
String signatureNote = signatureNoteObj instanceof String s ? s : null;
|
||||
if (signatureSvgObj instanceof String signatureSvg) {
|
||||
if (!signatureSvg.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
||||
signatureNote, completedBy);
|
||||
signatureRepository.save(signatureEntry);
|
||||
@@ -279,13 +303,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Signature completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processPhotoTaskCompletion(Map<String, Object> payload) {
|
||||
private void processPhotoTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -304,7 +328,7 @@ public class MessageController {
|
||||
|
||||
if (!photos.isEmpty()) {
|
||||
for (String photoString : photos) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||
photoRepository.save(photoEntry);
|
||||
}
|
||||
@@ -319,13 +343,13 @@ public class MessageController {
|
||||
extraDataSummary = "Keine Extra-Daten";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Photo completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void processCommentTaskCompletion(Map<String, Object> payload) {
|
||||
private void processCommentTaskCompletion(Map<String, Object> payload, String appUserId) {
|
||||
Object taskId = payload.get("taskId");
|
||||
try {
|
||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||
@@ -339,7 +363,7 @@ public class MessageController {
|
||||
if (extra instanceof Map<?, ?> extraData) {
|
||||
Object commentTextObj = extraData.get("commentText");
|
||||
if (commentTextObj instanceof String commentText && !commentText.isBlank()) {
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
|
||||
commentRepository.save(commentEntry);
|
||||
extraDataSummary = "Kommentar: " + commentText;
|
||||
@@ -350,13 +374,13 @@ public class MessageController {
|
||||
extraDataSummary = "Kommentar abgegeben";
|
||||
}
|
||||
|
||||
completeTaskWithHistory(taskId, extraDataSummary);
|
||||
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[TASK] Comment completion error: {}", ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTaskWithHistory(Object tid, String extraDataSummary) {
|
||||
private void completeTaskWithHistory(Object tid, String extraDataSummary, String appUserId) {
|
||||
String taskIdStr = tid.toString();
|
||||
try {
|
||||
ObjectId taskId = new ObjectId(taskIdStr);
|
||||
@@ -365,23 +389,23 @@ public class MessageController {
|
||||
return;
|
||||
}
|
||||
BaseTask task = opt.get();
|
||||
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
|
||||
if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) {
|
||||
return;
|
||||
}
|
||||
task.setCompletedBy(resolveTaskActor(task, appUserId));
|
||||
task.setCompleted(true);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
|
||||
if (jobOpt.isEmpty()) {
|
||||
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
|
||||
return;
|
||||
}
|
||||
ObjectId jobId = jobOpt.get().getId();
|
||||
|
||||
// Log detailed task completion in job history
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType;
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
||||
extraDataSummary);
|
||||
extraDataSummary, task.getDescription() != null ? task.getDescription() : "");
|
||||
} catch (Exception e) {
|
||||
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
|
||||
e.getMessage(), e);
|
||||
@@ -390,8 +414,9 @@ public class MessageController {
|
||||
// Send email notification for task completion
|
||||
try {
|
||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
||||
String completedBy = resolveTaskActor(task, appUserId);
|
||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy,
|
||||
task.getDescription());
|
||||
// Job completion is no longer auto-triggered by task completion.
|
||||
// It is now driven by explicit station_completed messages from the app
|
||||
// (see handleStationCompleted).
|
||||
@@ -406,55 +431,39 @@ public class MessageController {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
|
||||
try {
|
||||
var allTasks = taskAssignmentService.findTasksForJob(jobId);
|
||||
if (allTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mandatoryTasks = allTasks.stream().filter(task -> !task.isOptional()).toList();
|
||||
if (mandatoryTasks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean allCompleted = mandatoryTasks.stream().allMatch(task -> task.isCompleted());
|
||||
|
||||
if (allCompleted) {
|
||||
updateJobStatusToCompleted(jobId);
|
||||
try {
|
||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||
} catch (Exception e) {
|
||||
log.error("[JOB] Could not send job completion notification for job {}: {}", jobId, e.getMessage(),
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
List<String> openTaskIds = mandatoryTasks.stream().filter(task -> !task.isCompleted())
|
||||
.map(task -> task.getIdAsString()).toList();
|
||||
log.info("[JOB] Job {} not completed yet, {} of {} mandatory tasks still open: {}", jobId,
|
||||
openTaskIds.size(), mandatoryTasks.size(), openTaskIds);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[JOB] Completion check failed for job {}: {}", jobId, e.getMessage(), e);
|
||||
private 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 void updateJobStatusToCompleted(ObjectId jobId) {
|
||||
try {
|
||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||
if (jobOpt.isEmpty()) {
|
||||
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
|
||||
return;
|
||||
}
|
||||
private String resolveTaskActor(BaseTask task, String appUserId) {
|
||||
if (appUserId != null && !appUserId.isBlank()) {
|
||||
return appUserId;
|
||||
}
|
||||
return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||
}
|
||||
|
||||
Job job = jobOpt.get();
|
||||
if (job.getStatus() != JobStatus.COMPLETED) {
|
||||
job.setStatus(JobStatus.COMPLETED);
|
||||
job.setUpdatedAt(LocalDateTime.now());
|
||||
jobRepository.save(job);
|
||||
private boolean updateJobStatusToCompleted(ObjectId jobId) {
|
||||
try {
|
||||
Query query = Query.query(Criteria.where("_id").is(jobId)
|
||||
.and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED));
|
||||
Update update = new Update().set("status", JobStatus.COMPLETED).set("updatedAt", LocalDateTime.now());
|
||||
boolean completed = mongoTemplate.updateFirst(query, update, Job.class).getModifiedCount() > 0;
|
||||
if (completed) {
|
||||
log.info("[JOB] Job {} marked as COMPLETED", jobId);
|
||||
}
|
||||
return completed;
|
||||
} catch (Exception e) {
|
||||
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,8 +478,9 @@ public class MessageController {
|
||||
* "hasIncompleteOptionalTasks": false
|
||||
* }
|
||||
*
|
||||
* The job is marked as completed once this message is received and all
|
||||
* mandatory tasks across all stations are completed.
|
||||
* Intermediate stations send a station notification. The final route station
|
||||
* completes the job once all mandatory tasks are done, sending only the job
|
||||
* notification. Confirmations are recorded atomically to suppress duplicates.
|
||||
*/
|
||||
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
||||
try {
|
||||
@@ -490,19 +500,76 @@ public class MessageController {
|
||||
return;
|
||||
}
|
||||
|
||||
ObjectId jobId = jobOpt.get().getId();
|
||||
Job job = jobOpt.get();
|
||||
ObjectId jobId = job.getId();
|
||||
int stationOrder = Integer.parseInt(String.valueOf(payload.get("stationOrder")));
|
||||
List<DeliveryStation> stations = job.getDeliveryStations() != null
|
||||
? job.getDeliveryStations().stream().filter(Objects::nonNull).toList() : List.of();
|
||||
DeliveryStation station = stations.stream()
|
||||
.filter(candidate -> candidate.getStationOrder() == stationOrder).findFirst().orElse(null);
|
||||
if (station == null && !(stations.isEmpty() && stationOrder == 0)) {
|
||||
log.warn("[STATION] Unknown station {} for job {}", stationOrder, jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tasks = taskAssignmentService.findTasksForJob(jobId);
|
||||
boolean stationHasOpenMandatoryTasks = tasks.stream()
|
||||
.filter(task -> belongsToStation(task, station, stationOrder))
|
||||
.anyMatch(task -> !task.isOptional() && !task.isCompleted());
|
||||
if (stationHasOpenMandatoryTasks) {
|
||||
log.info("[STATION] Station {} of job {} still has open mandatory tasks", stationOrder, jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
String completedBy = appUserId != null ? appUserId : "Unknown";
|
||||
|
||||
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber,
|
||||
payload.get("stationOrder"));
|
||||
stationOrder);
|
||||
|
||||
checkAndHandleJobCompletion(jobId, completedBy);
|
||||
Query query = Query.query(Criteria.where("_id").is(jobId)
|
||||
.and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED)
|
||||
.and("completedStationOrders").ne(stationOrder));
|
||||
Update update = new Update().addToSet("completedStationOrders", stationOrder)
|
||||
.set("updatedAt", LocalDateTime.now());
|
||||
Job confirmedJob = mongoTemplate.findAndModify(query, update,
|
||||
FindAndModifyOptions.options().returnNew(true), Job.class);
|
||||
boolean firstConfirmation = confirmedJob != null;
|
||||
if (confirmedJob == null) {
|
||||
// A replay may finish a previously interrupted job transition,
|
||||
// but must never send another station notification.
|
||||
confirmedJob = jobRepository.findById(jobId).orElse(null);
|
||||
if (confirmedJob == null || confirmedJob.getStatus() == JobStatus.COMPLETED
|
||||
|| confirmedJob.getStatus() == JobStatus.CANCELLED
|
||||
|| confirmedJob.getCompletedStationOrders() == null
|
||||
|| !confirmedJob.getCompletedStationOrders().contains(stationOrder)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int lastStationOrder = stations.stream().mapToInt(DeliveryStation::getStationOrder).max().orElse(0);
|
||||
boolean lastStationConfirmed = confirmedJob.getCompletedStationOrders().contains(lastStationOrder);
|
||||
boolean allMandatoryTasksCompleted = tasks.stream().filter(task -> !task.isOptional())
|
||||
.allMatch(BaseTask::isCompleted);
|
||||
|
||||
if (lastStationConfirmed && allMandatoryTasksCompleted) {
|
||||
if (updateJobStatusToCompleted(jobId)) {
|
||||
emailService.sendJobCompletionNotification(jobId, completedBy);
|
||||
}
|
||||
} else if (firstConfirmation && stationOrder != lastStationOrder) {
|
||||
emailService.sendStationCompletionNotification(jobId, stationOrder, completedBy);
|
||||
}
|
||||
jobUpdateBroadcaster.broadcast(jobId);
|
||||
} catch (Exception e) {
|
||||
log.error("[STATION] Error handling station_completed: {}", e.getMessage());
|
||||
log.error("[STATION] Error handling station_completed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean belongsToStation(BaseTask task, DeliveryStation station, int stationOrder) {
|
||||
if (station != null && station.getStationId() != null && task.getStationId() != null) {
|
||||
return station.getStationId().equals(task.getStationId());
|
||||
}
|
||||
return (task.getStationOrder() != null ? task.getStationOrder() : 0) == stationOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from a client via WebSocket. Client sends to
|
||||
* /server/message with payload: { "content": "message payload", "contentType":
|
||||
|
||||
@@ -66,12 +66,12 @@ public class MessagingConfig {
|
||||
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null;
|
||||
messageController.handleTaskCompleted(payloadMap, taskType);
|
||||
messageController.handleTaskCompleted(payloadMap, taskType, appUserId);
|
||||
});
|
||||
});
|
||||
|
||||
// Station completion handler — marks a job as completed once all mandatory
|
||||
// tasks have been finished and the app confirms the station is done.
|
||||
// Station completion handler — notifies for intermediate stations and completes
|
||||
// the job after the final station and all mandatory tasks are done.
|
||||
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
|
||||
handlePayload(payload, payloadMap -> {
|
||||
messageController.handleStationCompleted(appUserId, payloadMap);
|
||||
|
||||
@@ -168,6 +168,9 @@ public class Job {
|
||||
@Field("delivery_stations")
|
||||
private List<DeliveryStation> deliveryStations = new ArrayList<>();
|
||||
|
||||
@Field("completed_station_orders")
|
||||
private List<Integer> completedStationOrders = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Returns the ObjectId as string for JSON serialization. This ensures that the
|
||||
* job id is returned as a string when jobs are retrieved via API.
|
||||
|
||||
@@ -49,6 +49,10 @@ public class JobHistory {
|
||||
*/
|
||||
private String details;
|
||||
|
||||
/** Task reference and description as recorded when the event occurred. */
|
||||
private ObjectId taskId;
|
||||
private String taskDescription;
|
||||
|
||||
/**
|
||||
* Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
|
||||
*/
|
||||
|
||||
+1
@@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog {
|
||||
|
||||
if (copy != null) {
|
||||
copy.setDescription(original.getDescription());
|
||||
copy.setOptional(original.isOptional());
|
||||
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
||||
copy.setCompleted(original.isCompleted());
|
||||
copy.setCompletedAt(original.getCompletedAt());
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
|
||||
|
||||
customerLabelMap.clear();
|
||||
for (Customer c : customers) {
|
||||
CustomerAddressLabelHelper.putName(customerLabelMap, c,
|
||||
CustomerAddressLabelHelper.put(customerLabelMap, c,
|
||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||
|
||||
@@ -172,7 +172,7 @@ public class AddJobService {
|
||||
* Generiert eine eindeutige Auftragsnummer
|
||||
*/
|
||||
private String generateJobNumber() {
|
||||
String prefix = "JOB";
|
||||
String prefix = "AUFTRAG";
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||
|
||||
// Zähle Aufträge des aktuellen Tages
|
||||
|
||||
@@ -52,15 +52,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
companyName = new TextField(getTranslation("profile.company"));
|
||||
companyName.setWidthFull();
|
||||
|
||||
// Steuernummer (Pflichtfeld)
|
||||
// Steuernummer (optional)
|
||||
taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
|
||||
taxNumber.setRequiredIndicatorVisible(true);
|
||||
taxNumber.setWidthFull();
|
||||
taxNumber.addBlurListener(e -> validateField(taxNumber));
|
||||
|
||||
// USt-IdNr. (Pflichtfeld)
|
||||
// USt-IdNr. (optional)
|
||||
ustId = new TextField(getTranslation("profile.billing.ustid"));
|
||||
ustId.setRequiredIndicatorVisible(true);
|
||||
ustId.setPlaceholder("DE123456789");
|
||||
ustId.setWidthFull();
|
||||
ustId.addBlurListener(e -> validateUstId());
|
||||
@@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
private void configureBinder() {
|
||||
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||
|
||||
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required"))
|
||||
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||
binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||
|
||||
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required"))
|
||||
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
|
||||
binder.forField(ustId)
|
||||
.withValidator(value -> value == null || value.isBlank()
|
||||
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
|
||||
getTranslation("profile.validation.ustid.invalid"))
|
||||
.bind(Customer::getUstId, Customer::setUstId);
|
||||
|
||||
@@ -265,15 +262,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
|
||||
* Prüft eine angegebene USt-IdNr. auf gültiges Format
|
||||
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
|
||||
*/
|
||||
private void validateUstId() {
|
||||
String value = ustId.getValue();
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
ustId.setInvalid(true);
|
||||
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
|
||||
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
|
||||
if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
|
||||
ustId.setInvalid(true);
|
||||
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
|
||||
} else {
|
||||
@@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
}
|
||||
|
||||
private boolean validateAllFields() {
|
||||
validateField(taxNumber);
|
||||
validateUstId();
|
||||
validateField(firstName);
|
||||
validateField(lastName);
|
||||
@@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
||||
validateField(city);
|
||||
validateEmail();
|
||||
|
||||
return !taxNumber.isInvalid() && !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
||||
return !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
||||
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
|
||||
&& !zip.isInvalid() && !city.isInvalid();
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||
customerLabelToEntity.clear();
|
||||
for (Customer c : ownerCustomers) {
|
||||
CustomerAddressLabelHelper.putName(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||
CustomerAddressLabelHelper.put(customerLabelToEntity, c, getTranslation("addjob.customer.unnamed"));
|
||||
}
|
||||
customerSelection.setItems(new ArrayList<>(customerLabelToEntity.keySet()));
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import de.assecutor.votianlt.repository.ServiceRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||
@@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
|
||||
currentUser = currentUserOpt.get();
|
||||
|
||||
Optional<InvoiceTemplate> templateOpt = invoiceTemplateService
|
||||
.getTemplateByUserId(currentUser.getId().toString());
|
||||
if (templateOpt.isEmpty()) {
|
||||
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
|
||||
Notification.Position.BOTTOM_END);
|
||||
return;
|
||||
}
|
||||
|
||||
String templateData = templateOpt.get().getTemplateData();
|
||||
InvoiceTemplate template = invoiceTemplateService
|
||||
.getCustomerTemplateByUserId(currentUser.getId().toString());
|
||||
String templateData = template.getTemplateData();
|
||||
if (templateData == null || templateData.isBlank()) {
|
||||
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
|
||||
Notification.Position.BOTTOM_END);
|
||||
@@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||
|
||||
// Build variable substitution map
|
||||
Map<String, String> variables = new HashMap<>();
|
||||
|
||||
// Master data (from user profile)
|
||||
variables.put("masterdata.company_name", safe(currentUser.getCompany()));
|
||||
variables.put("masterdata.contact_name", safe(currentUser.getFirstname()) + " " + safe(currentUser.getName()));
|
||||
variables.put("masterdata.street", safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber()));
|
||||
variables.put("masterdata.city", safe(currentUser.getZip()) + " " + safe(currentUser.getCity()));
|
||||
variables.put("masterdata.email", safe(currentUser.getEmail()));
|
||||
variables.put("masterdata.phone", safe(currentUser.getPhone()));
|
||||
Map<String, String> variables = CustomerInvoiceTemplateVariables.create(currentUser,
|
||||
userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber);
|
||||
|
||||
// Customer data (from job - look up Customer entity for full address)
|
||||
String customerSelection = currentJob.getCustomerSelection();
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import com.vaadin.flow.component.button.Button;
|
||||
import com.vaadin.flow.component.button.ButtonVariant;
|
||||
import com.vaadin.flow.component.combobox.ComboBox;
|
||||
import com.vaadin.flow.component.dialog.Dialog;
|
||||
import com.vaadin.flow.component.formlayout.FormLayout;
|
||||
import com.vaadin.flow.component.html.H2;
|
||||
@@ -34,7 +35,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
||||
private final Binder<Customer> binder = new Binder<>(Customer.class);
|
||||
|
||||
// Form fields - labels set in constructor via setLabel()
|
||||
private final TextField titleField = new TextField();
|
||||
private final ComboBox<String> titleField = new ComboBox<>();
|
||||
private final TextField companyNameField = new TextField();
|
||||
private final TextField taxNumberField = new TextField();
|
||||
private final TextField ustIdField = new TextField();
|
||||
@@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
||||
|
||||
// Set field labels via i18n
|
||||
titleField.setLabel(getTranslation("addjob.address.salutation"));
|
||||
titleField.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"),
|
||||
getTranslation("addjob.salutation.other"));
|
||||
titleField.setPlaceholder(getTranslation("addjob.address.salutation.placeholder"));
|
||||
companyNameField.setLabel(getTranslation("profile.company"));
|
||||
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
|
||||
taxNumberField.setRequiredIndicatorVisible(true);
|
||||
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
|
||||
ustIdField.setRequiredIndicatorVisible(true);
|
||||
ustIdField.setPlaceholder("DE123456789");
|
||||
firstnameField.setLabel(getTranslation("profile.firstname"));
|
||||
lastNameField.setLabel(getTranslation("profile.lastname"));
|
||||
@@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
||||
private void setupBinder() {
|
||||
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
|
||||
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required"))
|
||||
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required"))
|
||||
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
|
||||
binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||
binder.forField(ustIdField)
|
||||
.withValidator(value -> value == null || value.isBlank()
|
||||
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
|
||||
getTranslation("profile.validation.ustid.invalid"))
|
||||
.bind(Customer::getUstId, Customer::setUstId);
|
||||
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
|
||||
|
||||
@@ -44,6 +44,7 @@ import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||
import de.assecutor.votianlt.security.SecurityService;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
|
||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||
import de.assecutor.votianlt.service.LanguageService;
|
||||
import com.vaadin.flow.component.grid.Grid;
|
||||
@@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
private Checkbox billingEnabled;
|
||||
private NumberField vatRateField;
|
||||
private VerticalLayout propertiesPanelProfile;
|
||||
private final Div canvasContainer;
|
||||
|
||||
private final ServiceRepository serviceRepository;
|
||||
private Grid<Service> servicesGrid;
|
||||
@@ -414,7 +416,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
|
||||
|
||||
// Mitte: Canvas
|
||||
Div canvasContainer = new Div();
|
||||
canvasContainer = new Div();
|
||||
canvasContainer.setId("invoice-canvas-container-profile");
|
||||
canvasContainer.setWidth("100%");
|
||||
canvasContainer.setHeight("100%");
|
||||
@@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
// Bestehende Rechnungsdaten laden (nur für die Checkbox)
|
||||
loadInvoiceData();
|
||||
|
||||
// Initialize invoice generator when the billing tab is selected
|
||||
// Also register this view instance for JavaScript callbacks
|
||||
// Load directly; the client waits until the billing canvas is visible.
|
||||
tabSheet.addSelectedChangeListener(e -> {
|
||||
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
||||
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
|
||||
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
||||
+ " console.log('Canvas initialized, now loading template...'); "
|
||||
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
||||
+ " } else { " + " console.error('initProfileInvoiceGenerator not found'); " + " } "
|
||||
+ "}, 300);", getElement());
|
||||
loadInvoiceTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
|
||||
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
|
||||
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue());
|
||||
try {
|
||||
String valuesJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
|
||||
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData,
|
||||
prefixField.getValue() + "12345"));
|
||||
getElement().executeJs("if (window.updateProfileMasterdataValue) { "
|
||||
+ "Object.entries(JSON.parse($0)).forEach(([key, value]) => "
|
||||
+ "window.updateProfileMasterdataValue(key, value)); }", valuesJson);
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException ex) {
|
||||
throw new IllegalStateException("Could not update invoice template values", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
@@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
}
|
||||
BigDecimal previewVatRate = getPreviewVatRate();
|
||||
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
|
||||
currentUser, prefixField.getValue(), previewVatRate);
|
||||
currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData);
|
||||
showPdfInDialog(pdfBytes);
|
||||
} catch (Exception ex) {
|
||||
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
|
||||
@@ -1479,62 +1484,23 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by JavaScript when the canvas is ready
|
||||
*/
|
||||
@ClientCallable
|
||||
public void onCanvasReady() {
|
||||
System.out.println("Canvas ready, loading template...");
|
||||
loadInvoiceTemplate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the saved invoice template from database and display it on the canvas
|
||||
*/
|
||||
private void loadInvoiceTemplate() {
|
||||
try {
|
||||
java.util.Optional<de.assecutor.votianlt.model.InvoiceTemplate> optionalTemplate = invoiceTemplateService
|
||||
.getTemplateByUserId(currentUser.getId().toString());
|
||||
if (optionalTemplate.isPresent()) {
|
||||
String templateData = optionalTemplate.get().getTemplateData();
|
||||
if (templateData != null && !templateData.isEmpty()) {
|
||||
System.out.println("Loading template data: "
|
||||
+ templateData.substring(0, Math.min(100, templateData.length())) + "...");
|
||||
// Escape the JSON string for JavaScript
|
||||
String escapedJson = templateData.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
||||
.replace("\r", "\\r");
|
||||
// Build masterdata values JSON
|
||||
String company = safe(currentUser.getCompany());
|
||||
String fullName = safe(currentUser.getFirstname()) + " " + safe(currentUser.getName());
|
||||
String street = safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber());
|
||||
String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity());
|
||||
String email = safe(currentUser.getEmail());
|
||||
String phone = safe(currentUser.getPhone());
|
||||
String 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 templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString())
|
||||
.getTemplateData();
|
||||
if (templateData == null || templateData.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String prefix = prefixField.getValue();
|
||||
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
|
||||
String masterdataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
|
||||
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, invoiceNumber));
|
||||
getElement().executeJs("window.showProfileInvoiceTemplate($0, $1, JSON.parse($2), JSON.parse($3), $4);",
|
||||
canvasContainer.getElement(), getElement(), templateData, masterdataJson,
|
||||
getPreviewVatRate().doubleValue());
|
||||
} catch (Exception ex) {
|
||||
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
|
||||
@@ -209,6 +209,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
cardContent.add(description);
|
||||
}
|
||||
|
||||
String taskDescription = jobHistoryService.getTaskDescription(entry);
|
||||
if (!taskDescription.isBlank()) {
|
||||
Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + taskDescription);
|
||||
instructions.addClassName("timeline-description");
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
cardContent.add(instructions);
|
||||
}
|
||||
|
||||
// Photo preview for photo tasks
|
||||
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null
|
||||
&& entry.getDetails().contains("Task-Typ: PHOTO")) {
|
||||
@@ -239,9 +247,10 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
}
|
||||
|
||||
// Changed by (if available)
|
||||
if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) {
|
||||
Span changedBy = new Span(getTranslation("jobhistory.changedby", entry.getChangedBy()));
|
||||
// Author of this history entry.
|
||||
String createdBy = jobHistoryService.getCreatedByName(entry);
|
||||
if (createdBy != null && !createdBy.isBlank()) {
|
||||
Span changedBy = new Span(getTranslation("jobhistory.changedby", createdBy));
|
||||
changedBy.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
||||
.set("font-size", "var(--lumo-font-size-xs)").set("margin-top", "var(--lumo-space-xs)")
|
||||
.set("display", "block");
|
||||
@@ -260,7 +269,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||
return getTranslation("jobhistory.entry.create.reason");
|
||||
}
|
||||
return entry.getReason() != null ? entry.getReason() : "";
|
||||
return updateOrderTerminology(entry.getReason());
|
||||
}
|
||||
|
||||
private String getLocalizedDescription(JobHistory entry) {
|
||||
@@ -270,7 +279,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription()));
|
||||
}
|
||||
return entry.getDescription();
|
||||
return entry.getChangeType() == JobHistoryType.UPDATE || entry.getChangeType() == JobHistoryType.ASSIGNMENT
|
||||
? updateOrderTerminology(entry.getDescription()) : entry.getDescription();
|
||||
}
|
||||
|
||||
private String updateOrderTerminology(String text) {
|
||||
return text == null ? "" : text.replace("Job erstellt", "Auftrag erstellt")
|
||||
.replace("Job aktualisiert", "Auftrag aktualisiert").replace("Job-Daten", "Auftragsdaten")
|
||||
.replace("Job zugewiesen", "Auftrag zugewiesen").replace("Job-Zuweisung", "Auftragszuweisung");
|
||||
}
|
||||
|
||||
private String extractDescriptionValue(String description) {
|
||||
|
||||
@@ -555,6 +555,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
|
||||
private String summarizeDeliveryTask(BaseTask task) {
|
||||
String summary = summarizeDeliveryTaskType(task);
|
||||
String description = trimToNull(task.getDescription());
|
||||
return description != null && !summary.contains(description) ? summary + " – " + description : summary;
|
||||
}
|
||||
|
||||
private String summarizeDeliveryTaskType(BaseTask task) {
|
||||
if (task instanceof ConfirmationTask confirmationTask) {
|
||||
String buttonText = trimToNull(confirmationTask.getButtonText());
|
||||
if (buttonText != null) {
|
||||
@@ -1140,6 +1146,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
}
|
||||
dialogContent.add(statusSpan);
|
||||
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
Span instructions = new Span(getTranslation("addjob.tasks.description") + ": " + task.getDescription());
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
dialogContent.add(instructions);
|
||||
}
|
||||
|
||||
// Task-specific details
|
||||
addTaskSpecificDetails(dialogContent, task);
|
||||
|
||||
@@ -1150,7 +1162,8 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt())));
|
||||
}
|
||||
if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) {
|
||||
dialogContent.add(new Span("Abgeschlossen von: " + task.getCompletedBy()));
|
||||
dialogContent.add(new Span("Abgeschlossen von: "
|
||||
+ jobHistoryService.resolveUserName(task.getCompletedBy())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,6 +1387,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
||||
taskDescription.addClassName("job-task-description");
|
||||
|
||||
taskContent.add(headerRow, taskDescription);
|
||||
if (task.getDescription() != null && !task.getDescription().isBlank()) {
|
||||
Span instructions = new Span(task.getDescription());
|
||||
instructions.addClassName("job-task-description");
|
||||
instructions.getStyle().set("white-space", "pre-wrap").set("overflow-wrap", "anywhere");
|
||||
taskContent.add(instructions);
|
||||
}
|
||||
|
||||
// Status indicator
|
||||
Div statusIndicator = new Div();
|
||||
|
||||
@@ -263,6 +263,12 @@ public class CustomerInvoiceService {
|
||||
|
||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
||||
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, vatRate, null);
|
||||
}
|
||||
|
||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||
String invoicePrefix, BigDecimal vatRate, de.assecutor.votianlt.model.UserInvoiceData billing)
|
||||
throws Exception {
|
||||
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
||||
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
||||
|
||||
@@ -270,16 +276,8 @@ public class CustomerInvoiceService {
|
||||
java.util.Map<String, String> variables = new java.util.HashMap<>();
|
||||
|
||||
if (user != null) {
|
||||
// Use actual user data
|
||||
String company = user.getCompany();
|
||||
variables.put("masterdata.company_name", (company != null && !company.isEmpty()) ? company : "Ihre Firma");
|
||||
variables.put("masterdata.contact_name", safe(user.getFirstname()) + " " + safe(user.getName()));
|
||||
variables.put("masterdata.street", safe(user.getStreet()) + " " + safe(user.getHouseNumber()));
|
||||
variables.put("masterdata.city", safe(user.getZip()) + " " + safe(user.getCity()));
|
||||
variables.put("masterdata.email", safe(user.getEmail()));
|
||||
variables.put("masterdata.phone", safe(user.getPhone()));
|
||||
variables.put("masterdata.invoice_number",
|
||||
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345");
|
||||
variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing,
|
||||
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"));
|
||||
} else {
|
||||
// Default values for preview without user
|
||||
variables.put("masterdata.company_name", "Meine Firma GmbH");
|
||||
@@ -324,7 +322,7 @@ public class CustomerInvoiceService {
|
||||
htmlBuilder.append("<style>");
|
||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||
htmlBuilder.append(
|
||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
|
||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||
@@ -403,6 +401,9 @@ public class CustomerInvoiceService {
|
||||
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
||||
.append("mm;");
|
||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||
String fontStyle = element.path("fontStyle").asText("");
|
||||
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
|
||||
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
|
||||
htmlBuilder.append("line-height:")
|
||||
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
||||
htmlBuilder.append("color:").append(color).append(";");
|
||||
@@ -572,6 +573,14 @@ public class CustomerInvoiceService {
|
||||
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
|
||||
html.append("</tr>");
|
||||
|
||||
html.append("<tr>");
|
||||
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
|
||||
.append(vatLabel).append(" USt:</td>");
|
||||
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
|
||||
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal * vatRate.doubleValue()))
|
||||
.append("</td></tr>");
|
||||
|
||||
// Gesamtsumme - label in col 2, value in col 3
|
||||
html.append("<tr>");
|
||||
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||
@@ -626,7 +635,7 @@ public class CustomerInvoiceService {
|
||||
htmlBuilder.append("<style>");
|
||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||
htmlBuilder.append(
|
||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
|
||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||
@@ -718,6 +727,9 @@ public class CustomerInvoiceService {
|
||||
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
||||
.append("mm;");
|
||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||
String fontStyle = element.path("fontStyle").asText("");
|
||||
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
|
||||
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
|
||||
htmlBuilder.append("line-height:")
|
||||
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
||||
htmlBuilder.append("color:").append(color).append(";");
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.UserInvoiceData;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** Shared issuer values for the customer editor, PDF preview and actual invoice. */
|
||||
public final class CustomerInvoiceTemplateVariables {
|
||||
private CustomerInvoiceTemplateVariables() {
|
||||
}
|
||||
|
||||
public static Map<String, String> create(User user, UserInvoiceData billing, String invoiceNumber) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
String contact = join(" ", user.getFirstname(), user.getName());
|
||||
String company = safe(user.getCompany());
|
||||
if (company.isEmpty()) {
|
||||
company = contact;
|
||||
}
|
||||
String street = join(" ", user.getStreet(), user.getHouseNumber());
|
||||
String city = join(" ", user.getZip(), user.getCity());
|
||||
values.put("masterdata.company_name", company);
|
||||
values.put("masterdata.contact_name", contact);
|
||||
values.put("masterdata.street", street);
|
||||
values.put("masterdata.city", city);
|
||||
values.put("masterdata.email", safe(user.getEmail()));
|
||||
values.put("masterdata.phone", safe(user.getPhone()));
|
||||
values.put("masterdata.sender_line", join(" · ", company, street, city));
|
||||
values.put("masterdata.contact_details", join("\n", user.getEmail(), user.getPhone()));
|
||||
values.put("masterdata.invoice_number", safe(invoiceNumber));
|
||||
values.put("masterdata.invoice_date", LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
|
||||
values.put("masterdata.tax_details", billing == null ? ""
|
||||
: join("\n", labeled("USt-IdNr.: ", billing.getUstId()),
|
||||
labeled("Steuernummer: ", billing.getTaxNumber())));
|
||||
values.put("masterdata.bank_details", billing == null ? ""
|
||||
: join("\n", billing.getBankName(), labeled("IBAN: ", billing.getIban())));
|
||||
String intro = billing == null ? "" : safe(billing.getIntroText());
|
||||
values.put("masterdata.intro", intro.isEmpty() ? "Wir berechnen Ihnen folgende Leistungen:" : intro);
|
||||
String terms = billing == null ? "" : safe(billing.getPaymentTerms());
|
||||
values.put("masterdata.payment_terms", terms.isEmpty()
|
||||
? "Bitte geben Sie bei der Zahlung die Rechnungsnummer an." : terms);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String labeled(String label, String value) {
|
||||
return safe(value).isEmpty() ? "" : label + safe(value);
|
||||
}
|
||||
|
||||
private static String join(String separator, String... parts) {
|
||||
return Stream.of(parts).map(CustomerInvoiceTemplateVariables::safe).filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.joining(separator));
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.strip();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.Barcode;
|
||||
import de.assecutor.votianlt.model.Comment;
|
||||
import de.assecutor.votianlt.model.Customer;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobServiceSelection;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
@@ -37,9 +35,7 @@ import de.assecutor.votianlt.repository.TaskTemplateRepository;
|
||||
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import de.assecutor.votianlt.security.SessionAuthenticationService;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
@@ -54,7 +50,6 @@ import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -68,7 +63,6 @@ public class DemoModeService {
|
||||
public static final String DEMO_PASSWORD = "demo";
|
||||
private static final String DEMO_FIRST_NAME = "Demo";
|
||||
private static final String DEMO_LAST_NAME = "Benutzer";
|
||||
private static final String DEMO_INVOICE_TEMPLATE_RESOURCE = "templates/demo_invoice_template_default.json";
|
||||
|
||||
private final DemoSessionRegistry demoSessionRegistry;
|
||||
private final SessionAuthenticationService sessionAuthenticationService;
|
||||
@@ -194,7 +188,6 @@ public class DemoModeService {
|
||||
List<Service> services = seedDemoServices(demoUser);
|
||||
List<Customer> customers = seedDemoCustomers(demoUser);
|
||||
seedDemoJobs(demoUser, customers, services);
|
||||
seedDemoInvoiceTemplate(demoUser);
|
||||
}
|
||||
|
||||
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
|
||||
@@ -441,23 +434,6 @@ public class DemoModeService {
|
||||
return selection;
|
||||
}
|
||||
|
||||
private void seedDemoInvoiceTemplate(User demoUser) {
|
||||
InvoiceTemplate template = new InvoiceTemplate(demoUser.getId().toHexString(), "Einfach",
|
||||
createDemoInvoiceTemplateData());
|
||||
invoiceTemplateRepository.save(template);
|
||||
}
|
||||
|
||||
private String createDemoInvoiceTemplateData() {
|
||||
try {
|
||||
String templateJson = new String(
|
||||
new ClassPathResource(DEMO_INVOICE_TEMPLATE_RESOURCE).getInputStream().readAllBytes(),
|
||||
StandardCharsets.UTF_8);
|
||||
return new ObjectMapper().writeValueAsString(templateJson);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Could not load demo invoice template", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCustomerToPickup(Job job, Customer customer) {
|
||||
if (job == null || customer == null) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
@@ -20,6 +21,8 @@ import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -35,7 +38,8 @@ public class EmailService {
|
||||
private String smtpUsername;
|
||||
|
||||
@Async
|
||||
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy) {
|
||||
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||
String taskDescription) {
|
||||
try {
|
||||
// Load job to get context information
|
||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||
@@ -62,7 +66,7 @@ public class EmailService {
|
||||
String completedByName = resolveCompletedByName(job, completedBy);
|
||||
|
||||
// Send email
|
||||
sendEmail(user, job, taskType, completedByName);
|
||||
sendEmail(user, job, taskType, completedByName, taskDescription);
|
||||
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
|
||||
taskId);
|
||||
|
||||
@@ -72,12 +76,12 @@ public class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
private void sendEmail(User user, Job job, String taskType, String completedByName) {
|
||||
private void sendEmail(User user, Job job, String taskType, String completedByName, String taskDescription) {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Aufgabe abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
String taskTypeName = getTaskTypeDisplayName(taskType);
|
||||
@@ -85,11 +89,14 @@ public class EmailService {
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||
}
|
||||
body.append("Aufgabe: ").append(taskTypeName).append("\n");
|
||||
if (taskDescription != null && !taskDescription.isBlank()) {
|
||||
body.append("Beschreibung: ").append(taskDescription.strip()).append("\n");
|
||||
}
|
||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||
|
||||
String deliveryCities = job.getDeliveryCitiesDisplay();
|
||||
@@ -195,6 +202,69 @@ public class EmailService {
|
||||
};
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendStationCompletionNotification(ObjectId jobId, int stationOrder, String completedBy) {
|
||||
try {
|
||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||
if (jobOpt.isEmpty()) {
|
||||
log.warn("Job not found for station completion notification: {}", jobId);
|
||||
return;
|
||||
}
|
||||
Job job = jobOpt.get();
|
||||
DeliveryStation station = job.getDeliveryStations() == null ? null
|
||||
: job.getDeliveryStations().stream().filter(candidate -> candidate != null
|
||||
&& candidate.getStationOrder() == stationOrder).findFirst().orElse(null);
|
||||
if (station == null) {
|
||||
log.warn("Station {} not found for completion notification for job {}", stationOrder, jobId);
|
||||
return;
|
||||
}
|
||||
Optional<User> userOpt = userRepository.findById(new ObjectId(job.getCreatedBy()));
|
||||
if (userOpt.isEmpty() || userOpt.get().getEmail() == null || userOpt.get().getEmail().isBlank()) {
|
||||
log.warn("Recipient missing for station completion notification for job {}", jobId);
|
||||
return;
|
||||
}
|
||||
User user = userOpt.get();
|
||||
String jobNumber = job.getJobNumber() != null ? job.getJobNumber() : jobId.toHexString();
|
||||
String completedByName = findAppUserById(completedBy).map(this::buildAppUserName)
|
||||
.orElseGet(() -> resolveCompletedByName(job, completedBy));
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject("Station abgeschlossen - " + jobNumber + " - Station " + (stationOrder + 1));
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(buildFullName(user)).append(",\n\n");
|
||||
body.append("die folgende Station wurde abgeschlossen:\n\n");
|
||||
body.append("Auftrag: ").append(jobNumber).append("\n");
|
||||
body.append("Station: ").append(stationOrder + 1).append("\n");
|
||||
appendStationAddressLine(body, station.getCompany());
|
||||
appendStationAddressLine(body, joinAddressParts(station.getFirstName(), station.getLastName()));
|
||||
appendStationAddressLine(body, joinAddressParts(station.getStreet(), station.getHouseNumber()));
|
||||
appendStationAddressLine(body, station.getAddressAddition());
|
||||
appendStationAddressLine(body, joinAddressParts(station.getZip(), station.getCity()));
|
||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||
body.append("Mit freundlichen Grüßen,\nIhr Votianlt-System");
|
||||
message.setText(body.toString());
|
||||
mailSender.send(message);
|
||||
log.info("Station completion notification sent to {} for job {} station {}", user.getEmail(), jobNumber,
|
||||
stationOrder);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send station completion notification for job {} station {}: {}", jobId, stationOrder,
|
||||
e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendStationAddressLine(StringBuilder body, String line) {
|
||||
if (line != null && !line.isBlank()) {
|
||||
body.append(line.trim()).append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private String joinAddressParts(String... parts) {
|
||||
return Stream.of(parts).filter(part -> part != null && !part.isBlank()).map(String::trim)
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
@Async
|
||||
public void sendJobCompletionNotification(ObjectId jobId, String completedBy) {
|
||||
try {
|
||||
@@ -236,7 +306,7 @@ public class EmailService {
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Job abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Auftrag abgeschlossen - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
|
||||
@@ -246,8 +316,8 @@ public class EmailService {
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("alle Aufgaben für den folgenden Auftrag wurden erfolgreich abgeschlossen:\n\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||
}
|
||||
@@ -270,7 +340,7 @@ public class EmailService {
|
||||
body.append("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
|
||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||
|
||||
body.append("Der Job ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
||||
body.append("Der Auftrag ist nun vollständig erledigt und kann weiterverarbeitet werden.\n\n");
|
||||
body.append("Mit freundlichen Grüßen,\n");
|
||||
body.append("Ihr Votianlt-System");
|
||||
|
||||
@@ -325,7 +395,7 @@ public class EmailService {
|
||||
message.setFrom(smtpUsername);
|
||||
message.setTo(user.getEmail());
|
||||
message.setSubject(
|
||||
"Neuer Job erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Job " + job.getId()));
|
||||
"Neuer Auftrag erstellt - " + (job.getJobNumber() != null ? job.getJobNumber() : "Auftrag " + job.getId()));
|
||||
|
||||
String fullName = buildFullName(user);
|
||||
|
||||
@@ -335,8 +405,8 @@ public class EmailService {
|
||||
|
||||
StringBuilder body = new StringBuilder();
|
||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
||||
body.append("Job: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
body.append("ein neuer Auftrag wurde erfolgreich erstellt:\n\n");
|
||||
body.append("Auftrag: ").append(job.getJobNumber() != null ? job.getJobNumber() : "Unbekannt").append("\n");
|
||||
|
||||
if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
||||
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
||||
@@ -374,7 +444,7 @@ public class EmailService {
|
||||
: "Unbekannt")
|
||||
.append("\n\n");
|
||||
|
||||
body.append("Der Job ist nun im System verfügbar und kann bearbeitet werden.\n\n");
|
||||
body.append("Der Auftrag ist nun im System verfügbar und kann bearbeitet werden.\n\n");
|
||||
body.append("Mit freundlichen Grüßen,\n");
|
||||
body.append("Ihr Votianlt-System");
|
||||
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvoiceTemplateService {
|
||||
|
||||
public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT";
|
||||
|
||||
private final InvoiceTemplateRepository invoiceTemplateRepository;
|
||||
|
||||
/**
|
||||
@@ -36,6 +43,49 @@ public class InvoiceTemplateService {
|
||||
return invoiceTemplateRepository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer invoices use the neutral default if no usable personal template is
|
||||
* stored. Reading the default never creates or overwrites a database record.
|
||||
*/
|
||||
public InvoiceTemplate getCustomerTemplateByUserId(String userId) {
|
||||
if (userId == null || userId.isBlank() || "SYSTEM".equals(userId) || CUSTOMER_DEFAULT_USER_ID.equals(userId)) {
|
||||
throw new IllegalArgumentException("A customer user ID is required");
|
||||
}
|
||||
return invoiceTemplateRepository.findByUserId(userId)
|
||||
.filter(template -> hasTemplateElements(template.getTemplateData()))
|
||||
.orElseGet(() -> invoiceTemplateRepository.findByUserId(CUSTOMER_DEFAULT_USER_ID)
|
||||
.filter(template -> hasTemplateElements(template.getTemplateData()))
|
||||
// Saving the customer's edits must never change the shared default.
|
||||
.map(template -> new InvoiceTemplate(userId, template.getName(), template.getTemplateData()))
|
||||
.orElseGet(() -> loadBundledCustomerDefault(userId)));
|
||||
}
|
||||
|
||||
private InvoiceTemplate loadBundledCustomerDefault(String userId) {
|
||||
try (var input = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) {
|
||||
String json = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return new InvoiceTemplate(userId, "Neutral", new ObjectMapper().writeValueAsString(json));
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Could not load neutral customer invoice template", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasTemplateElements(String templateData) {
|
||||
if (templateData == null || templateData.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode data = mapper.readTree(templateData);
|
||||
// Templates can be stored as an object or as a JSON-encoded string.
|
||||
if (data != null && data.isTextual()) {
|
||||
data = mapper.readTree(data.textValue());
|
||||
}
|
||||
return data != null && data.path("elements").isArray() && !data.path("elements").isEmpty();
|
||||
} catch (IOException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists for a user
|
||||
*/
|
||||
|
||||
@@ -5,12 +5,20 @@ import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobHistory;
|
||||
import de.assecutor.votianlt.model.JobHistoryType;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.JobHistoryRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -18,10 +26,20 @@ public class JobHistoryService {
|
||||
|
||||
private final JobHistoryRepository jobHistoryRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TaskRepository taskRepository;
|
||||
private final JobRepository jobRepository;
|
||||
|
||||
public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper) {
|
||||
public JobHistoryService(JobHistoryRepository jobHistoryRepository, ObjectMapper objectMapper,
|
||||
AppUserRepository appUserRepository, UserRepository userRepository, TaskRepository taskRepository,
|
||||
JobRepository jobRepository) {
|
||||
this.jobHistoryRepository = jobHistoryRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
this.jobRepository = jobRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,9 +47,9 @@ public class JobHistoryService {
|
||||
*/
|
||||
public void logJobCreation(Job job, String createdBy) {
|
||||
try {
|
||||
JobHistory history = new JobHistory(job.getId(), "Job erstellt",
|
||||
"Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
||||
createdBy, JobHistoryType.CREATE, null, "Job erstellt");
|
||||
JobHistory history = new JobHistory(job.getId(), "Auftrag erstellt",
|
||||
"Neuer Auftrag wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
||||
createdBy, JobHistoryType.CREATE, null, "Auftrag erstellt");
|
||||
|
||||
if (job.getDeliveryCompany() != null) {
|
||||
history.setDetails("Kunde: " + job.getDeliveryCompany());
|
||||
@@ -70,7 +88,7 @@ public class JobHistoryService {
|
||||
try {
|
||||
String description = generateUpdateDescription(oldJob, newJob);
|
||||
|
||||
JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Job aktualisiert",
|
||||
JobHistory history = new JobHistory(newJob.getId(), reason != null ? reason : "Auftrag aktualisiert",
|
||||
description, changedBy, JobHistoryType.UPDATE, serializeJobForComparison(oldJob),
|
||||
serializeJobForComparison(newJob));
|
||||
|
||||
@@ -93,6 +111,11 @@ public class JobHistoryService {
|
||||
*/
|
||||
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||
String taskDisplayName, String extraDataSummary) {
|
||||
logTaskCompletion(jobId, taskType, taskId, completedBy, taskDisplayName, extraDataSummary, null);
|
||||
}
|
||||
|
||||
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||
String taskDisplayName, String extraDataSummary, String taskDescription) {
|
||||
try {
|
||||
String taskName = taskDisplayName != null ? taskDisplayName : taskType;
|
||||
String description = String.format("Aufgabe abgeschlossen: %s", taskName);
|
||||
@@ -101,8 +124,10 @@ public class JobHistoryService {
|
||||
description += " - " + extraDataSummary;
|
||||
}
|
||||
|
||||
JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, completedBy,
|
||||
JobHistory history = new JobHistory(jobId, "Aufgabe abgeschlossen", description, resolveUserName(completedBy),
|
||||
JobHistoryType.TASK_COMPLETED, "In Bearbeitung", "Abgeschlossen");
|
||||
history.setTaskId(ObjectId.isValid(taskId) ? new ObjectId(taskId) : null);
|
||||
history.setTaskDescription(taskDescription);
|
||||
|
||||
// Detaillierte Informationen in details speichern
|
||||
StringBuilder details = new StringBuilder();
|
||||
@@ -132,11 +157,11 @@ public class JobHistoryService {
|
||||
try {
|
||||
String description;
|
||||
if (oldAssignee == null && newAssignee != null) {
|
||||
description = "Job zugewiesen an: " + newAssignee;
|
||||
description = "Auftrag zugewiesen an: " + newAssignee;
|
||||
} else if (oldAssignee != null && newAssignee == null) {
|
||||
description = "Job-Zuweisung entfernt von: " + oldAssignee;
|
||||
description = "Auftragszuweisung entfernt von: " + oldAssignee;
|
||||
} else {
|
||||
description = String.format("Job-Zuweisung geändert von %s zu %s", oldAssignee, newAssignee);
|
||||
description = String.format("Auftragszuweisung geändert von %s zu %s", oldAssignee, newAssignee);
|
||||
}
|
||||
|
||||
JobHistory history = new JobHistory(job.getId(), "Zuweisung geändert", description, changedBy,
|
||||
@@ -177,6 +202,85 @@ public class JobHistoryService {
|
||||
return jobHistoryRepository.countByJobId(jobId);
|
||||
}
|
||||
|
||||
public String getTaskDescription(JobHistory entry) {
|
||||
if (entry.getTaskDescription() != null) {
|
||||
return entry.getTaskDescription();
|
||||
}
|
||||
return findHistoryTask(entry).map(BaseTask::getDescription).orElse("");
|
||||
}
|
||||
|
||||
public String getCreatedByName(JobHistory entry) {
|
||||
if (!isUnknownUser(entry.getChangedBy())) {
|
||||
return resolveUserName(entry.getChangedBy());
|
||||
}
|
||||
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED) {
|
||||
Optional<String> taskActor = findHistoryTask(entry).map(BaseTask::getCompletedBy)
|
||||
.filter(actor -> !isUnknownUser(actor));
|
||||
if (taskActor.isPresent()) {
|
||||
return resolveUserName(taskActor.get());
|
||||
}
|
||||
// Old events did not record the authenticated actor. Identify the
|
||||
// current assignee explicitly rather than presenting it as certain.
|
||||
if (entry.getJobId() != null) {
|
||||
Optional<String> assignedUser = jobRepository.findById(entry.getJobId()).map(Job::getAppUser)
|
||||
.filter(actor -> !isUnknownUser(actor));
|
||||
if (assignedUser.isPresent()) {
|
||||
return resolveUserName(assignedUser.get()) + " (zugewiesener App-Nutzer)";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unbekannt";
|
||||
}
|
||||
|
||||
public String resolveUserName(String actor) {
|
||||
if (isUnknownUser(actor)) {
|
||||
return "Unbekannt";
|
||||
}
|
||||
if (!ObjectId.isValid(actor)) {
|
||||
return actor;
|
||||
}
|
||||
ObjectId id = new ObjectId(actor);
|
||||
var appUser = appUserRepository.findById(id);
|
||||
if (appUser.isPresent()) {
|
||||
var user = appUser.get();
|
||||
return firstNonBlank(joinName(user.getVorname(), user.getNachname()), user.getBezeichnung(),
|
||||
user.getEmail(), actor);
|
||||
}
|
||||
return userRepository.findById(id)
|
||||
.map(user -> firstNonBlank(joinName(user.getTitle(), user.getFirstname(), user.getName()),
|
||||
user.getEmail(), actor))
|
||||
.orElse(actor);
|
||||
}
|
||||
|
||||
private Optional<BaseTask> findHistoryTask(JobHistory entry) {
|
||||
if (entry.getChangeType() != JobHistoryType.TASK_COMPLETED) {
|
||||
return Optional.empty();
|
||||
}
|
||||
ObjectId taskId = entry.getTaskId();
|
||||
if (taskId == null && entry.getDetails() != null) {
|
||||
var matcher = java.util.regex.Pattern.compile("(?:^|,\\s*)Task-ID: ([0-9a-fA-F]{24})(?:,|$)")
|
||||
.matcher(entry.getDetails());
|
||||
if (matcher.find()) {
|
||||
taskId = new ObjectId(matcher.group(1));
|
||||
}
|
||||
}
|
||||
return taskId != null ? taskRepository.findById(taskId) : Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isUnknownUser(String actor) {
|
||||
return actor == null || actor.isBlank() || "Unknown".equalsIgnoreCase(actor)
|
||||
|| "Unkown".equalsIgnoreCase(actor) || "Unbekannt".equalsIgnoreCase(actor);
|
||||
}
|
||||
|
||||
private String joinName(String... parts) {
|
||||
return Stream.of(parts).filter(part -> part != null && !part.isBlank()).map(String::trim)
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
return Stream.of(values).filter(value -> value != null && !value.isBlank()).findFirst().orElse("");
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private String formatStatus(JobStatus status) {
|
||||
@@ -197,7 +301,7 @@ public class JobHistoryService {
|
||||
}
|
||||
|
||||
private String generateUpdateDescription(Job oldJob, Job newJob) {
|
||||
StringBuilder description = new StringBuilder("Job-Daten aktualisiert");
|
||||
StringBuilder description = new StringBuilder("Auftragsdaten aktualisiert");
|
||||
|
||||
if (oldJob == null) {
|
||||
return description.toString();
|
||||
|
||||
@@ -260,7 +260,7 @@ page.title.statistics=Statistiken
|
||||
page.title.password.forget=Passwort zurücksetzen
|
||||
page.title.invoices=Rechnungen
|
||||
page.title.appusers=App-Nutzer
|
||||
page.title.job.history=Job Historie
|
||||
page.title.job.history=Auftrags-Historie
|
||||
page.title.message.history=Nachrichtenverlauf
|
||||
page.title.myinvoices=Rechnungen
|
||||
page.title.job.create=Neuen Auftrag anlegen
|
||||
@@ -583,11 +583,11 @@ addjob.validation.address.correct=Adresse korrigieren
|
||||
|
||||
# Job Summary
|
||||
jobsummary.title=Zusammenfassung
|
||||
jobsummary.error.noid=Keine Job-ID angegeben
|
||||
jobsummary.error.invalidid=Ungültige Job-ID Format: {0}
|
||||
jobsummary.error.notfound=Job mit ID {0} nicht gefunden
|
||||
jobsummary.error.noid=Keine Auftrags-ID angegeben
|
||||
jobsummary.error.invalidid=Ungültiges Format der Auftrags-ID: {0}
|
||||
jobsummary.error.notfound=Auftrag mit ID {0} nicht gefunden
|
||||
jobsummary.button.sendmessage=Nachricht senden
|
||||
jobsummary.button.jobhistory=Job Historie
|
||||
jobsummary.button.jobhistory=Auftrags-Historie
|
||||
jobsummary.button.complete=Auftrag manuell abschließen
|
||||
jobsummary.dialog.complete.title=Auftrag abschließen
|
||||
jobsummary.dialog.complete.text=Möchten Sie den Auftrag {0} manuell abschließen?
|
||||
@@ -669,8 +669,8 @@ jobs.notification.delete.error=Fehler beim Löschen: {0}
|
||||
|
||||
# Create Invoice
|
||||
createinvoice.title=Rechnung erstellen \u2013 Auftrag {0}
|
||||
createinvoice.error.invalidid=Ungültige Job-ID
|
||||
createinvoice.error.notfound=Job nicht gefunden
|
||||
createinvoice.error.invalidid=Ungültige Auftrags-ID
|
||||
createinvoice.error.notfound=Auftrag nicht gefunden
|
||||
createinvoice.button.create=Rechnung erstellen
|
||||
createinvoice.section.job=Auftragsdetails
|
||||
createinvoice.section.route=Streckeninfo
|
||||
@@ -986,7 +986,7 @@ csv.header.customer=Auftraggeber
|
||||
csv.header.jobnumber=Auftragsnummer
|
||||
csv.header.jobdate=Auftragsdatum
|
||||
csv.header.destination=Zielort
|
||||
csv.filename=jobs.csv
|
||||
csv.filename=auftraege.csv
|
||||
|
||||
# DatePicker I18n
|
||||
datepicker.month.januar=Januar
|
||||
@@ -1022,15 +1022,15 @@ jobhistory.status.pickedup=Abgeholt
|
||||
jobhistory.status.intransit=Unterwegs
|
||||
jobhistory.status.delivered=Zugestellt
|
||||
jobhistory.image.alt=Vergrößertes Foto
|
||||
jobhistory.title=Jobhistorie
|
||||
jobhistory.header=Jobhistorie für {0}
|
||||
jobhistory.title=Auftrags-Historie
|
||||
jobhistory.header=Auftrags-Historie für {0}
|
||||
jobhistory.info.customer=Kunde: {0}
|
||||
jobhistory.info.createdat=Erstellt am: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} Einträge in der Historie
|
||||
jobhistory.changedby=Geändert von: {0}
|
||||
jobhistory.entry.create.reason=Job erstellt
|
||||
jobhistory.entry.create.description=Neuer Job wurde erstellt: {0}
|
||||
jobhistory.changedby=Erstellt von: {0}
|
||||
jobhistory.entry.create.reason=Auftrag erstellt
|
||||
jobhistory.entry.create.description=Neuer Auftrag wurde erstellt: {0}
|
||||
|
||||
# Version
|
||||
version.label=Version
|
||||
|
||||
@@ -870,7 +870,7 @@ jobhistory.info.customer=Klient: {0}
|
||||
jobhistory.info.createdat=Loodud: {0}
|
||||
jobhistory.info.status=Olek: {0}
|
||||
jobhistory.count={0} kirjet ajaloos
|
||||
jobhistory.changedby=Muutnud: {0}
|
||||
jobhistory.changedby=Loonud: {0}
|
||||
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
|
||||
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
|
||||
version.label=Versioon
|
||||
|
||||
@@ -1028,7 +1028,7 @@ jobhistory.info.customer=Customer: {0}
|
||||
jobhistory.info.createdat=Created on: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} entries in history
|
||||
jobhistory.changedby=Changed by: {0}
|
||||
jobhistory.changedby=Created by: {0}
|
||||
jobhistory.entry.create.reason=Job Created
|
||||
jobhistory.entry.create.description=New job was created: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Cliente: {0}
|
||||
jobhistory.info.createdat=Creado el: {0}
|
||||
jobhistory.info.status=Estado: {0}
|
||||
jobhistory.count={0} entradas en el historial
|
||||
jobhistory.changedby=Modificado por: {0}
|
||||
jobhistory.changedby=Creado por: {0}
|
||||
jobhistory.entry.create.reason=Pedido creado
|
||||
jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Client : {0}
|
||||
jobhistory.info.createdat=Cr\u00e9\u00e9 le : {0}
|
||||
jobhistory.info.status=Statut : {0}
|
||||
jobhistory.count={0} entr\u00e9es dans l'historique
|
||||
jobhistory.changedby=Modifi\u00e9 par : {0}
|
||||
jobhistory.changedby=Créé par : {0}
|
||||
jobhistory.entry.create.reason=Mission cr\u00e9\u00e9e
|
||||
jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0}
|
||||
|
||||
|
||||
@@ -961,7 +961,7 @@ jobhistory.info.customer=Klientas: {0}
|
||||
jobhistory.info.createdat=Sukurta: {0}
|
||||
jobhistory.info.status=Būsena: {0}
|
||||
jobhistory.count={0} įrašų istorijoje
|
||||
jobhistory.changedby=Pakeitė: {0}
|
||||
jobhistory.changedby=Sukūrė: {0}
|
||||
jobhistory.entry.create.reason=Užsakymas sukurtas
|
||||
jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klients: {0}
|
||||
jobhistory.info.createdat=Izveidots: {0}
|
||||
jobhistory.info.status=Statuss: {0}
|
||||
jobhistory.count={0} ieraksti vēsturē
|
||||
jobhistory.changedby=Mainīja: {0}
|
||||
jobhistory.changedby=Izveidoja: {0}
|
||||
jobhistory.entry.create.reason=Uzdevums izveidots
|
||||
jobhistory.entry.create.description=Izveidots jauns uzdevums: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Klient: {0}
|
||||
jobhistory.info.createdat=Utworzono dnia: {0}
|
||||
jobhistory.info.status=Status: {0}
|
||||
jobhistory.count={0} wpis\u00f3w w historii
|
||||
jobhistory.changedby=Zmienione przez: {0}
|
||||
jobhistory.changedby=Utworzone przez: {0}
|
||||
jobhistory.entry.create.reason=Zlecenie utworzone
|
||||
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Клиент: {0}
|
||||
jobhistory.info.createdat=Создано: {0}
|
||||
jobhistory.info.status=Статус: {0}
|
||||
jobhistory.count={0} записей в истории
|
||||
jobhistory.changedby=Изменено: {0}
|
||||
jobhistory.changedby=Создано: {0}
|
||||
jobhistory.entry.create.reason=Заказ создан
|
||||
jobhistory.entry.create.description=Создан новый заказ: {0}
|
||||
|
||||
|
||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=M\u00fc\u015fteri: {0}
|
||||
jobhistory.info.createdat=Olu\u015fturulma Tarihi: {0}
|
||||
jobhistory.info.status=Durum: {0}
|
||||
jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t
|
||||
jobhistory.changedby=De\u011fi\u015ftiren: {0}
|
||||
jobhistory.changedby=Oluşturan: {0}
|
||||
jobhistory.entry.create.reason=\u0130\u015f olu\u015fturuldu
|
||||
jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"id": "neutral-company",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "5.70071",
|
||||
"widthPercent": "61.51261",
|
||||
"heightPercent": "3.56295",
|
||||
"fontSize": 20,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.company_name"
|
||||
},
|
||||
{
|
||||
"id": "neutral-document-label",
|
||||
"type": "text",
|
||||
"text": "RECHNUNG",
|
||||
"xPercent": "71.93277",
|
||||
"yPercent": "6.88836",
|
||||
"widthPercent": "19.32773",
|
||||
"heightPercent": "2.13777",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "right",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-street",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "10.80760",
|
||||
"widthPercent": "57.14286",
|
||||
"heightPercent": "1.66271",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.street"
|
||||
},
|
||||
{
|
||||
"id": "neutral-city",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "12.58907",
|
||||
"widthPercent": "57.14286",
|
||||
"heightPercent": "1.66271",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.city"
|
||||
},
|
||||
{
|
||||
"id": "neutral-header-rule",
|
||||
"type": "line",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "16.50831",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "0.11876",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-sender",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "20.30879",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "1.18765",
|
||||
"fontSize": 7,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.sender_line"
|
||||
},
|
||||
{
|
||||
"id": "neutral-recipient-company_name",
|
||||
"type": "text",
|
||||
"text": "Kundenfirma GmbH",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "23.27791",
|
||||
"widthPercent": "47.89916",
|
||||
"heightPercent": "1.78147",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.company_name"
|
||||
},
|
||||
{
|
||||
"id": "neutral-recipient-contact_name",
|
||||
"type": "text",
|
||||
"text": "Erika Mustermann",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "25.05938",
|
||||
"widthPercent": "47.89916",
|
||||
"heightPercent": "1.78147",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.contact_name"
|
||||
},
|
||||
{
|
||||
"id": "neutral-recipient-street",
|
||||
"type": "text",
|
||||
"text": "Kundenstraße 456",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "26.84086",
|
||||
"widthPercent": "47.89916",
|
||||
"heightPercent": "1.78147",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.street"
|
||||
},
|
||||
{
|
||||
"id": "neutral-recipient-city",
|
||||
"type": "text",
|
||||
"text": "54321 Kundenstadt",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "28.62233",
|
||||
"widthPercent": "47.89916",
|
||||
"heightPercent": "1.78147",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.city"
|
||||
},
|
||||
{
|
||||
"id": "neutral-number-label",
|
||||
"type": "text",
|
||||
"text": "RECHNUNGSNUMMER",
|
||||
"xPercent": "63.52941",
|
||||
"yPercent": "23.15914",
|
||||
"widthPercent": "27.73109",
|
||||
"heightPercent": "1.42518",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-number",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "63.52941",
|
||||
"yPercent": "25.05938",
|
||||
"widthPercent": "27.73109",
|
||||
"heightPercent": "1.90024",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.invoice_number"
|
||||
},
|
||||
{
|
||||
"id": "neutral-date-label",
|
||||
"type": "text",
|
||||
"text": "RECHNUNGSDATUM",
|
||||
"xPercent": "63.52941",
|
||||
"yPercent": "28.38480",
|
||||
"widthPercent": "27.73109",
|
||||
"heightPercent": "1.42518",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-date",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "63.52941",
|
||||
"yPercent": "30.28504",
|
||||
"widthPercent": "27.73109",
|
||||
"heightPercent": "1.90024",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.invoice_date"
|
||||
},
|
||||
{
|
||||
"id": "neutral-services-heading",
|
||||
"type": "text",
|
||||
"text": "Leistungen",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "36.34204",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "2.37530",
|
||||
"fontSize": 14,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-intro",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "39.90499",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "3.56295",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.intro"
|
||||
},
|
||||
{
|
||||
"id": "neutral-services",
|
||||
"type": "text",
|
||||
"text": "Rechnungspositionen",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "45.24941",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "27.67221",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "services.list"
|
||||
},
|
||||
{
|
||||
"id": "neutral-payment-label",
|
||||
"type": "text",
|
||||
"text": "ZAHLUNG",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "78.02850",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "1.42518",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-payment",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "80.40380",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "4.98812",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.payment_terms"
|
||||
},
|
||||
{
|
||||
"id": "neutral-footer-rule",
|
||||
"type": "line",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "88.83610",
|
||||
"widthPercent": "82.52101",
|
||||
"heightPercent": "0.11876",
|
||||
"fontSize": 10,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#202020",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-contact-label",
|
||||
"type": "text",
|
||||
"text": "KONTAKT",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "90.61758",
|
||||
"widthPercent": "26.21849",
|
||||
"heightPercent": "1.30641",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-contact",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "8.73950",
|
||||
"yPercent": "92.87411",
|
||||
"widthPercent": "26.21849",
|
||||
"heightPercent": "3.32542",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#444444",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.contact_details"
|
||||
},
|
||||
{
|
||||
"id": "neutral-tax-label",
|
||||
"type": "text",
|
||||
"text": "STEUERANGABEN",
|
||||
"xPercent": "38.65546",
|
||||
"yPercent": "90.61758",
|
||||
"widthPercent": "25.37815",
|
||||
"heightPercent": "1.30641",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-tax",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "38.65546",
|
||||
"yPercent": "92.87411",
|
||||
"widthPercent": "25.37815",
|
||||
"heightPercent": "3.32542",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#444444",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.tax_details"
|
||||
},
|
||||
{
|
||||
"id": "neutral-bank-label",
|
||||
"type": "text",
|
||||
"text": "BANKVERBINDUNG",
|
||||
"xPercent": "67.22689",
|
||||
"yPercent": "90.61758",
|
||||
"widthPercent": "24.03361",
|
||||
"heightPercent": "1.30641",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#555555",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "neutral-bank",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "67.22689",
|
||||
"yPercent": "92.87411",
|
||||
"widthPercent": "24.03361",
|
||||
"heightPercent": "3.32542",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#444444",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.bank_details"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"id": "demo-title",
|
||||
"type": "header",
|
||||
"text": "Rechnung",
|
||||
"xPercent": "7.56",
|
||||
"yPercent": "6.53",
|
||||
"widthPercent": "28.07",
|
||||
"heightPercent": "4.04",
|
||||
"fontSize": 24,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-number-label",
|
||||
"type": "text",
|
||||
"text": "Rechnungsnr.",
|
||||
"xPercent": "67.06",
|
||||
"yPercent": "7.48",
|
||||
"widthPercent": "17.98",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-number-value",
|
||||
"type": "text",
|
||||
"text": "000000",
|
||||
"xPercent": "84.03",
|
||||
"yPercent": "7.48",
|
||||
"widthPercent": "10.08",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "invoice.number"
|
||||
},
|
||||
{
|
||||
"id": "demo-date-label",
|
||||
"type": "text",
|
||||
"text": "Datum",
|
||||
"xPercent": "67.06",
|
||||
"yPercent": "11.52",
|
||||
"widthPercent": "17.98",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-date-value",
|
||||
"type": "text",
|
||||
"text": "2026-01-01",
|
||||
"xPercent": "84.03",
|
||||
"yPercent": "11.52",
|
||||
"widthPercent": "10.08",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "invoice.date"
|
||||
},
|
||||
{
|
||||
"id": "demo-sender",
|
||||
"type": "text",
|
||||
"text": null,
|
||||
"xPercent": "7.56",
|
||||
"yPercent": "16.51",
|
||||
"widthPercent": "30.08",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "masterdata.contact_name"
|
||||
},
|
||||
{
|
||||
"id": "demo-customer",
|
||||
"type": "customer",
|
||||
"text": "Kundenname\nStraße Nr.\nPLZ Ort",
|
||||
"xPercent": "7.56",
|
||||
"yPercent": "25.06",
|
||||
"widthPercent": "33.95",
|
||||
"heightPercent": "12.00",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-services",
|
||||
"type": "text",
|
||||
"text": "Leistungen",
|
||||
"xPercent": "7.56",
|
||||
"yPercent": "42.04",
|
||||
"widthPercent": "85.04",
|
||||
"heightPercent": "28.03",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "services.list"
|
||||
},
|
||||
{
|
||||
"id": "demo-net-label",
|
||||
"type": "text",
|
||||
"text": "Netto",
|
||||
"xPercent": "67.06",
|
||||
"yPercent": "73.99",
|
||||
"widthPercent": "11.93",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-net-value",
|
||||
"type": "text",
|
||||
"text": "0,00 €",
|
||||
"xPercent": "80.00",
|
||||
"yPercent": "73.99",
|
||||
"widthPercent": "13.95",
|
||||
"heightPercent": "2.97",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "invoice.net_total"
|
||||
},
|
||||
{
|
||||
"id": "demo-total-label",
|
||||
"type": "text",
|
||||
"text": "Gesamt",
|
||||
"xPercent": "67.06",
|
||||
"yPercent": "78.98",
|
||||
"widthPercent": "11.93",
|
||||
"heightPercent": "3.44",
|
||||
"fontSize": 13,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111"
|
||||
},
|
||||
{
|
||||
"id": "demo-total-value",
|
||||
"type": "text",
|
||||
"text": "0,00 €",
|
||||
"xPercent": "80.00",
|
||||
"yPercent": "78.98",
|
||||
"widthPercent": "13.95",
|
||||
"heightPercent": "3.44",
|
||||
"fontSize": 13,
|
||||
"fontStyle": "bold",
|
||||
"color": "#111111",
|
||||
"isStatic": true,
|
||||
"variable": "invoice.gross_total"
|
||||
},
|
||||
{
|
||||
"id": "demo-template-note",
|
||||
"type": "text",
|
||||
"text": "Hinweis: Dieses Rechnungstemplate kann vom Benutzer ",
|
||||
"xPercent": "0.00",
|
||||
"yPercent": "20.78",
|
||||
"widthPercent": "100.00",
|
||||
"heightPercent": "4.51",
|
||||
"fontSize": 20,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "center",
|
||||
"color": "#555555"
|
||||
},
|
||||
{
|
||||
"id": "element-1",
|
||||
"type": "text",
|
||||
"text": "frei angepasst werden.",
|
||||
"xPercent": "31.93",
|
||||
"yPercent": "24.94",
|
||||
"widthPercent": "25.21",
|
||||
"heightPercent": "3.56",
|
||||
"fontSize": 20,
|
||||
"color": "#333333",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"id": "hha-letterhead",
|
||||
"type": "image",
|
||||
"text": "",
|
||||
"xPercent": "0.00000",
|
||||
"yPercent": "0.00000",
|
||||
"widthPercent": "100.00000",
|
||||
"heightPercent": "100.00000",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null,
|
||||
"imageData": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNTk1IiBoZWlnaHQ9Ijg0MiIgdmlld0JveD0iMCAwIDU5NSA4NDIiPgogIDxkZWZzPgogICAgPGltYWdlIGlkPSJvcmlnaW5hbC1sb2dvIiB4PSIwIiB5PSIwIiB3aWR0aD0iNTk1IiBoZWlnaHQ9IjcwLjg4IiB4bGluazpocmVmPSJkYXRhOmltYWdlL2pwZWc7YmFzZTY0LC85ai80QUFRU2taSlJnQUJBUUFBQVFBQkFBRC8yd0JEQUFNQ0FnTUNBZ01EQXdNRUF3TUVCUWdGQlFRRUJRb0hCd1lJREFvTURBc0tDd3NORGhJUURRNFJEZ3NMRUJZUUVSTVVGUlVWREE4WEdCWVVHQklVRlJULzJ3QkRBUU1FQkFVRUJRa0ZCUWtVRFFzTkZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlFVRkJRVUZCUVVGQlQvd2dBUkNBSlBFMkVEQVNJQUFoRUJBeEVCLzhRQUhRQUJBUUFEQVFFQkFRRUFBQUFBQUFBQUFBZ0dCd2tGQkFNQkF2L0VBQnNCQVFBQkJRRUFBQUFBQUFBQUFBQUFBQUFEQVFJRUJRWUgvOW9BREFNQkFBSVFBeEFBQUFHcVFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQLine truncated
|
||||
},
|
||||
{
|
||||
"id": "hha-issuer-street",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "13.42043",
|
||||
"widthPercent": "29.07563",
|
||||
"heightPercent": "1.56770",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#1a11b9",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.street"
|
||||
},
|
||||
{
|
||||
"id": "hha-issuer-city",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "14.98812",
|
||||
"widthPercent": "29.07563",
|
||||
"heightPercent": "1.56770",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#1a11b9",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.city"
|
||||
},
|
||||
{
|
||||
"id": "hha-phone-label",
|
||||
"type": "text",
|
||||
"text": "Tel.:",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "17.93349",
|
||||
"widthPercent": "4.36975",
|
||||
"heightPercent": "1.56770",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#1a11b9",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-issuer-phone",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "68.73950",
|
||||
"yPercent": "17.93349",
|
||||
"widthPercent": "24.03361",
|
||||
"heightPercent": "1.56770",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#1a11b9",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.phone"
|
||||
},
|
||||
{
|
||||
"id": "hha-issuer-website",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "19.50119",
|
||||
"widthPercent": "28.57143",
|
||||
"heightPercent": "1.56770",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#1a11b9",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.website"
|
||||
},
|
||||
{
|
||||
"id": "hha-sender-line",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "18.64608",
|
||||
"widthPercent": "52.94118",
|
||||
"heightPercent": "0.99762",
|
||||
"fontSize": 7,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.sender_line"
|
||||
},
|
||||
{
|
||||
"id": "hha-recipient-company_name",
|
||||
"type": "text",
|
||||
"text": "Musterfirma GmbH",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "20.19002",
|
||||
"widthPercent": "51.26050",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.company_name"
|
||||
},
|
||||
{
|
||||
"id": "hha-recipient-contact_name",
|
||||
"type": "text",
|
||||
"text": "Max Mustermann",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "21.90024",
|
||||
"widthPercent": "51.26050",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.contact_name"
|
||||
},
|
||||
{
|
||||
"id": "hha-recipient-street",
|
||||
"type": "text",
|
||||
"text": "Musterstraße 1",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "23.61045",
|
||||
"widthPercent": "51.26050",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.street"
|
||||
},
|
||||
{
|
||||
"id": "hha-recipient-city",
|
||||
"type": "text",
|
||||
"text": "12345 Musterstadt",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "25.32067",
|
||||
"widthPercent": "51.26050",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": true,
|
||||
"variable": "customer.city"
|
||||
},
|
||||
{
|
||||
"id": "hha-fax-label",
|
||||
"type": "text",
|
||||
"text": "Fax",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "27.07838",
|
||||
"widthPercent": "8.40336",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-fax-number",
|
||||
"type": "text",
|
||||
"text": "040-181237719",
|
||||
"xPercent": "64.36975",
|
||||
"yPercent": "28.50356",
|
||||
"widthPercent": "9.74790",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-email-label",
|
||||
"type": "text",
|
||||
"text": "E-Mail",
|
||||
"xPercent": "74.45378",
|
||||
"yPercent": "27.07838",
|
||||
"widthPercent": "17.81513",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-issuer-email",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "74.45378",
|
||||
"yPercent": "28.50356",
|
||||
"widthPercent": "18.65546",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.email"
|
||||
},
|
||||
{
|
||||
"id": "hha-your-reference",
|
||||
"type": "text",
|
||||
"text": "Ihr Zeichen, Ihre Nachricht vom",
|
||||
"xPercent": "11.42857",
|
||||
"yPercent": "35.51069",
|
||||
"widthPercent": "23.19328",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-our-reference",
|
||||
"type": "text",
|
||||
"text": "Unser Zeichen, unsere Nachricht vom",
|
||||
"xPercent": "35.63025",
|
||||
"yPercent": "35.51069",
|
||||
"widthPercent": "24.36975",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-contact-label",
|
||||
"type": "text",
|
||||
"text": "Telefon, Name",
|
||||
"xPercent": "59.83193",
|
||||
"yPercent": "35.51069",
|
||||
"widthPercent": "16.80672",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-date-label",
|
||||
"type": "text",
|
||||
"text": "Datum",
|
||||
"xPercent": "84.03361",
|
||||
"yPercent": "35.51069",
|
||||
"widthPercent": "8.73950",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-invoice-date",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "84.03361",
|
||||
"yPercent": "36.93587",
|
||||
"widthPercent": "8.90756",
|
||||
"heightPercent": "1.14014",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.invoice_date"
|
||||
},
|
||||
{
|
||||
"id": "hha-invoice-title",
|
||||
"type": "header",
|
||||
"text": "Rechnung",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "40.38005",
|
||||
"widthPercent": "10.75630",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-invoice-number",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "20.33613",
|
||||
"yPercent": "40.38005",
|
||||
"widthPercent": "50.42017",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "bold",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.invoice_number"
|
||||
},
|
||||
{
|
||||
"id": "hha-intro",
|
||||
"type": "text",
|
||||
"text": "Gemäß unserem Nutzungsvertrag berechnen wir Ihnen\nfolgende Leistungen:",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "44.53682",
|
||||
"widthPercent": "83.36134",
|
||||
"heightPercent": "3.42043",
|
||||
"fontSize": 12,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-positions",
|
||||
"type": "text",
|
||||
"text": "Rechnungspositionen",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "49.04988",
|
||||
"widthPercent": "83.36134",
|
||||
"heightPercent": "26.72209",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "services.list"
|
||||
},
|
||||
{
|
||||
"id": "hha-payment-label",
|
||||
"type": "text",
|
||||
"text": "Zahlungsbedingungen:",
|
||||
"xPercent": "9.57983",
|
||||
"yPercent": "85.03563",
|
||||
"widthPercent": "22.68908",
|
||||
"heightPercent": "1.71021",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-payment-text",
|
||||
"type": "text",
|
||||
"text": "Gesamtbetrag bis spätestens zum 10. Werktag nach\nRechnungserhalt auf unser u. g. Konto.",
|
||||
"xPercent": "33.27731",
|
||||
"yPercent": "85.03563",
|
||||
"widthPercent": "59.66387",
|
||||
"heightPercent": "3.42043",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "left",
|
||||
"color": "#000000",
|
||||
"isStatic": false,
|
||||
"isCustomer": false,
|
||||
"variable": null
|
||||
},
|
||||
{
|
||||
"id": "hha-footer",
|
||||
"type": "text",
|
||||
"text": "",
|
||||
"xPercent": "9.24370",
|
||||
"yPercent": "92.75534",
|
||||
"widthPercent": "84.03361",
|
||||
"heightPercent": "3.42043",
|
||||
"fontSize": 8,
|
||||
"fontStyle": "normal",
|
||||
"textAlign": "center",
|
||||
"color": "#000000",
|
||||
"isStatic": true,
|
||||
"isCustomer": false,
|
||||
"variable": "masterdata.footer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const generator = readFileSync(new URL('../../main/frontend/invoice-generator/profile-invoice-generator.js', import.meta.url), 'utf8');
|
||||
|
||||
function createEditor() {
|
||||
const painted = [];
|
||||
const pendingImages = [];
|
||||
const context = new Proxy({
|
||||
measureText: text => ({ width: text.length * 6 }),
|
||||
drawImage: image => painted.push(['image', image.src]),
|
||||
fillText: text => painted.push(['text', text])
|
||||
}, { get: (target, key) => target[key] ?? (() => {}) });
|
||||
const canvas = { style: {}, getContext: () => context, addEventListener() {}, setAttribute() {} };
|
||||
const container = {
|
||||
style: {},
|
||||
getBoundingClientRect: () => ({ width: 615, height: 862 }),
|
||||
appendChild() {},
|
||||
addEventListener() {}
|
||||
};
|
||||
const window = { addEventListener() {} };
|
||||
const resizeObservers = [];
|
||||
class ResizeObserver {
|
||||
constructor(callback) { this.callback = callback; resizeObservers.push(this); }
|
||||
observe() {}
|
||||
disconnect() { this.disconnected = true; }
|
||||
}
|
||||
class Image {
|
||||
width = 595;
|
||||
height = 842;
|
||||
constructor() { pendingImages.push(this); }
|
||||
}
|
||||
vm.runInNewContext(generator, {
|
||||
window,
|
||||
document: { getElementById: () => container, createElement: () => canvas },
|
||||
Image,
|
||||
ResizeObserver,
|
||||
console: { log() {}, error(error) { throw new Error(String(error)); } }
|
||||
});
|
||||
window.initProfileInvoiceGenerator();
|
||||
window.loadProfileTemplate({ elements: [
|
||||
{ id: 'letterhead', type: 'image', imageData: 'data:image/svg+xml;base64,test', x: 0, y: 0, width: 595, height: 842 },
|
||||
{ id: 'invoice-text', type: 'text', text: 'Rechnung', x: 50, y: 250, width: 100, height: 30 }
|
||||
] });
|
||||
return { window, painted, pendingImages, container, resizeObservers };
|
||||
}
|
||||
|
||||
test('invoice text stays above a letterhead that finishes loading later', () => {
|
||||
const { painted, pendingImages } = createEditor();
|
||||
assert.deepEqual(painted, [['text', 'Rechnung']]);
|
||||
painted.length = 0;
|
||||
pendingImages[0].onload();
|
||||
assert.deepEqual(painted, [
|
||||
['image', 'data:image/svg+xml;base64,test'],
|
||||
['text', 'Rechnung']
|
||||
]);
|
||||
});
|
||||
|
||||
test('a removed letterhead is not painted when its pending image finishes loading', () => {
|
||||
const { window, painted, pendingImages } = createEditor();
|
||||
window.deleteProfileElement('letterhead');
|
||||
painted.length = 0;
|
||||
pendingImages[0].onload();
|
||||
assert.deepEqual(painted, [['text', 'Rechnung']]);
|
||||
});
|
||||
|
||||
test('optional billing data stays empty while unknown variables retain a placeholder', () => {
|
||||
const { window } = createEditor();
|
||||
window.masterdataValues = { 'masterdata.bank_details': '' };
|
||||
window.loadProfileTemplate({ elements: [
|
||||
{ id: 'bank', type: 'text', variable: 'masterdata.bank_details', text: 'Old bank', x: 0, y: 0 },
|
||||
{ id: 'unknown', type: 'text', variable: 'masterdata.unknown', x: 0, y: 20 }
|
||||
] });
|
||||
assert.equal(window.profileInvoiceState.elements[0].text, '');
|
||||
assert.equal(window.profileInvoiceState.elements[1].text, '[unknown]');
|
||||
});
|
||||
|
||||
const neutralTemplate = JSON.parse(readFileSync(new URL('../../main/resources/templates/customer_invoice_template_neutral.json', import.meta.url), 'utf8'));
|
||||
|
||||
test('the neutral profile template loads when its initially hidden tab becomes visible', () => {
|
||||
const { window, container, resizeObservers } = createEditor();
|
||||
container.getBoundingClientRect = () => ({ width: 0, height: 0 });
|
||||
window.showProfileInvoiceTemplate(container, {}, JSON.stringify(neutralTemplate), { 'masterdata.company_name': 'Kundenfirma' }, 0.19);
|
||||
assert.equal(resizeObservers.length, 1);
|
||||
assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead');
|
||||
|
||||
container.getBoundingClientRect = () => ({ width: 615, height: 862 });
|
||||
resizeObservers[0].callback();
|
||||
|
||||
assert.equal(window.profileInvoiceState.elements.length, neutralTemplate.elements.length);
|
||||
assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company');
|
||||
assert.equal(window.profileInvoiceState.elements[0].text, 'Kundenfirma');
|
||||
assert.equal(resizeObservers[0].disconnected, true);
|
||||
assert.equal(window.pendingProfileInvoiceLoad, null);
|
||||
});
|
||||
|
||||
test('opening another customer profile cancels a pending template load', () => {
|
||||
const { window, container, resizeObservers } = createEditor();
|
||||
container.getBoundingClientRect = () => ({ width: 0, height: 0 });
|
||||
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Vorheriger Kunde' }, 0.19);
|
||||
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Aktueller Kunde' }, 0.07);
|
||||
assert.equal(resizeObservers[0].disconnected, true);
|
||||
|
||||
container.getBoundingClientRect = () => ({ width: 615, height: 862 });
|
||||
resizeObservers[0].callback();
|
||||
assert.equal(window.profileInvoiceState.elements[0].id, 'letterhead');
|
||||
resizeObservers[1].callback();
|
||||
assert.equal(window.profileInvoiceState.elements[0].text, 'Aktueller Kunde');
|
||||
assert.equal(window.profileInvoiceVatRate, 0.07);
|
||||
});
|
||||
|
||||
test('a visible profile canvas loads the template immediately', () => {
|
||||
const { window, container, resizeObservers } = createEditor();
|
||||
window.showProfileInvoiceTemplate(container, {}, neutralTemplate, { 'masterdata.company_name': 'Kundenfirma' }, 0.19);
|
||||
assert.equal(resizeObservers.length, 0);
|
||||
assert.equal(window.profileInvoiceState.elements[0].id, 'neutral-company');
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mongodb.client.result.UpdateResult;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
import de.assecutor.votianlt.service.JobUpdateBroadcaster;
|
||||
import de.assecutor.votianlt.service.TaskAssignmentService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.bson.Document;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
import org.springframework.data.mongodb.core.query.Query;
|
||||
import org.springframework.data.mongodb.core.query.Update;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StationCompletionTest {
|
||||
|
||||
@Mock
|
||||
private JobRepository jobRepository;
|
||||
@Mock
|
||||
private TaskAssignmentService taskAssignmentService;
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
@Mock
|
||||
private JobUpdateBroadcaster jobUpdateBroadcaster;
|
||||
@Mock
|
||||
private MongoTemplate mongoTemplate;
|
||||
@InjectMocks
|
||||
private MessageController controller;
|
||||
|
||||
private final ObjectId jobId = new ObjectId();
|
||||
private Job job;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
job = new Job();
|
||||
job.setId(jobId);
|
||||
job.setJobNumber("AUFTRAG20260929001");
|
||||
job.setDeliveryStations(List.of(station(0), station(1)));
|
||||
when(jobRepository.findById(jobId)).thenReturn(Optional.of(job));
|
||||
}
|
||||
|
||||
@Test
|
||||
void intermediateStationSendsOnlyStationEmailEvenWhenAllTasksAreDone() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalStationSendsOnlyJobEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(1);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleStationWithUnfinishedOptionalTaskSendsOnlyJobEmail() {
|
||||
job.setDeliveryStations(List.of(station(0)));
|
||||
ConfirmationTask optional = task(0, false);
|
||||
optional.setOptional(true);
|
||||
tasks(task(0, true), optional);
|
||||
confirmOnce(0);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyJobWithoutEmbeddedStationsStillCompletes() {
|
||||
job.setDeliveryStations(null);
|
||||
tasks(task(0, true));
|
||||
confirmOnce(0);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedIntermediateStationSendsOnlyOneEmail() {
|
||||
tasks(task(0, true), task(1, false));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedFinalStationSendsOnlyOneJobEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||
.thenReturn(UpdateResult.acknowledged(1, 1L, null), UpdateResult.acknowledged(0, 0L, null));
|
||||
|
||||
complete(1);
|
||||
complete(1);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayCanFinishInterruptedJobCompletionWithoutStationEmail() {
|
||||
tasks(task(0, true), task(1, true));
|
||||
confirmOnce(1);
|
||||
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||
.thenReturn(UpdateResult.acknowledged(0, 0L, null), UpdateResult.acknowledged(1, 1L, null));
|
||||
|
||||
complete(1);
|
||||
complete(1);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = JobStatus.class, names = { "COMPLETED", "CANCELLED" })
|
||||
void closedJobDoesNotSendAnotherEmail(JobStatus status) {
|
||||
job.setStatus(status);
|
||||
job.setCompletedStationOrders(List.of(0, 1));
|
||||
tasks(task(0, true), task(1, true));
|
||||
|
||||
complete(1);
|
||||
|
||||
verifyNoInteractions(emailService);
|
||||
verify(mongoTemplate, never()).updateFirst(any(Query.class), any(Update.class), eq(Job.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void incompleteMandatoryTaskPreventsStationCompletion() {
|
||||
tasks(task(0, false), task(1, true));
|
||||
|
||||
complete(0);
|
||||
|
||||
verifyNoInteractions(mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationIdTakesPrecedenceOverStaleTaskOrder() {
|
||||
ConfirmationTask openTask = task(1, false);
|
||||
openTask.setStationId(job.getDeliveryStations().getFirst().getStationId());
|
||||
tasks(openTask);
|
||||
|
||||
complete(0);
|
||||
|
||||
verifyNoInteractions(mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownStationIsIgnored() {
|
||||
complete(7);
|
||||
|
||||
verifyNoInteractions(taskAssignmentService, mongoTemplate, emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalStationIsDeterminedByRouteOrderForJobsWithoutTasks() {
|
||||
job.setDeliveryStations(List.of(station(7), station(2)));
|
||||
tasks();
|
||||
confirmOnce(7);
|
||||
allowJobCompletion();
|
||||
|
||||
complete(7);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void intermediateStationWithoutTasksStillSendsStationEmail() {
|
||||
tasks();
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendStationCompletionNotification(jobId, 0, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalConfirmationWaitsForEarlierMandatoryWorkThenSendsOnlyJobEmail() {
|
||||
ConfirmationTask earlierTask = task(0, false);
|
||||
tasks(earlierTask, task(1, true));
|
||||
confirmOnce(1);
|
||||
|
||||
complete(1);
|
||||
verifyNoInteractions(emailService);
|
||||
earlierTask.setCompleted(true);
|
||||
job.setCompletedStationOrders(List.of(0, 1));
|
||||
when(mongoTemplate.findAndModify(any(Query.class), any(Update.class),
|
||||
any(FindAndModifyOptions.class), eq(Job.class))).thenReturn(job);
|
||||
allowJobCompletion();
|
||||
complete(0);
|
||||
|
||||
verify(emailService).sendJobCompletionNotification(jobId, "driver-1");
|
||||
verifyNoMoreInteractions(emailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmationIsPersistedAtomicallyAndExcludesClosedJobs() {
|
||||
tasks(task(0, true), task(1, false));
|
||||
confirmOnce(0);
|
||||
|
||||
complete(0);
|
||||
|
||||
ArgumentCaptor<Query> query = ArgumentCaptor.forClass(Query.class);
|
||||
ArgumentCaptor<Update> update = ArgumentCaptor.forClass(Update.class);
|
||||
ArgumentCaptor<FindAndModifyOptions> options = ArgumentCaptor.forClass(FindAndModifyOptions.class);
|
||||
verify(mongoTemplate).findAndModify(query.capture(), update.capture(), options.capture(), eq(Job.class));
|
||||
assertThat(query.getValue().getQueryObject()).containsEntry("_id", jobId)
|
||||
.containsEntry("completedStationOrders", new Document("$ne", 0))
|
||||
.containsEntry("status", new Document("$nin", List.of(JobStatus.COMPLETED, JobStatus.CANCELLED)));
|
||||
assertThat(update.getValue().getUpdateObject()).containsEntry("$addToSet",
|
||||
new Document("completedStationOrders", 0));
|
||||
assertThat(options.getValue().isReturnNew()).isTrue();
|
||||
}
|
||||
|
||||
private void tasks(BaseTask... tasks) {
|
||||
when(taskAssignmentService.findTasksForJob(jobId)).thenReturn(List.of(tasks));
|
||||
}
|
||||
|
||||
private void confirmOnce(int stationOrder) {
|
||||
job.setCompletedStationOrders(List.of(stationOrder));
|
||||
when(mongoTemplate.findAndModify(any(Query.class), any(Update.class),
|
||||
any(FindAndModifyOptions.class), eq(Job.class))).thenReturn(job).thenReturn(null);
|
||||
}
|
||||
|
||||
private void allowJobCompletion() {
|
||||
when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(Job.class)))
|
||||
.thenReturn(UpdateResult.acknowledged(1, 1L, null));
|
||||
}
|
||||
|
||||
private void complete(int stationOrder) {
|
||||
controller.handleStationCompleted("driver-1", Map.of("jobId", jobId.toHexString(),
|
||||
"jobNumber", job.getJobNumber(), "stationOrder", stationOrder));
|
||||
}
|
||||
|
||||
private DeliveryStation station(int order) {
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setStationId(new ObjectId());
|
||||
station.setStationOrder(order);
|
||||
return station;
|
||||
}
|
||||
|
||||
private ConfirmationTask task(int stationOrder, boolean completed) {
|
||||
ConfirmationTask task = new ConfirmationTask("Lieferung bestätigen");
|
||||
task.setStationOrder(stationOrder);
|
||||
task.setCompleted(completed);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package de.assecutor.votianlt.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.BarcodeTask;
|
||||
import de.assecutor.votianlt.model.task.CommentTask;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
import de.assecutor.votianlt.model.task.PhotoTask;
|
||||
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||
import de.assecutor.votianlt.model.task.TodoListTask;
|
||||
import de.assecutor.votianlt.repository.BarcodeRepository;
|
||||
import de.assecutor.votianlt.repository.CommentRepository;
|
||||
import de.assecutor.votianlt.repository.PhotoRepository;
|
||||
import de.assecutor.votianlt.repository.SignatureRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import de.assecutor.votianlt.service.EmailService;
|
||||
import de.assecutor.votianlt.service.JobHistoryService;
|
||||
import de.assecutor.votianlt.service.JobUpdateBroadcaster;
|
||||
import de.assecutor.votianlt.service.TaskAssignmentService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskCompletionHistoryTest {
|
||||
@Mock
|
||||
private TaskRepository taskRepository;
|
||||
@Mock
|
||||
private PhotoRepository photoRepository;
|
||||
@Mock
|
||||
private SignatureRepository signatureRepository;
|
||||
@Mock
|
||||
private BarcodeRepository barcodeRepository;
|
||||
@Mock
|
||||
private CommentRepository commentRepository;
|
||||
@Mock
|
||||
private TaskAssignmentService taskAssignmentService;
|
||||
@Mock
|
||||
private JobHistoryService jobHistoryService;
|
||||
@Mock
|
||||
private JobUpdateBroadcaster jobUpdateBroadcaster;
|
||||
@Mock
|
||||
private EmailService emailService;
|
||||
@InjectMocks
|
||||
private MessageController controller;
|
||||
|
||||
static Stream<BaseTask> tasks() {
|
||||
return Stream.of(new ConfirmationTask(), new PhotoTask(), new SignatureTask(), new BarcodeTask(),
|
||||
new CommentTask(), new TodoListTask());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void authenticatedActorAndInstructionsArePreservedForEveryTaskType(BaseTask task) {
|
||||
String actor = new ObjectId().toHexString();
|
||||
task.setId(new ObjectId());
|
||||
task.setDescription("Am Seiteneingang abgeben.");
|
||||
task.setCompletedBy("Unknown");
|
||||
Job job = new Job();
|
||||
job.setId(new ObjectId());
|
||||
job.setCompletedStationOrders(List.of(1));
|
||||
when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task));
|
||||
when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job));
|
||||
Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
|
||||
"signatureSvg", "<svg/>", "signatureNote", "Paket beschädigt", "commentText", "Am Empfang abgegeben");
|
||||
Map<String, Object> payload = Map.of("taskId", task.getIdAsString(), "completedBy", "untrusted-payload-user",
|
||||
"extraData", extra);
|
||||
|
||||
controller.handleTaskCompleted(payload, task.getTaskType(), actor);
|
||||
|
||||
assertThat(task.isCompleted()).isTrue();
|
||||
assertThat(task.getCompletedBy()).isEqualTo(actor);
|
||||
verify(taskRepository).save(task);
|
||||
verify(jobHistoryService).logTaskCompletion(eq(job.getId()), eq(task.getTaskType()), eq(task.getIdAsString()),
|
||||
eq(actor), eq(task.getDisplayName()), anyString(), eq(task.getDescription()));
|
||||
verify(emailService).sendTaskCompletionNotification(job.getId(), task.getTaskType(), task.getIdAsString(),
|
||||
actor, "Am Seiteneingang abgeben.");
|
||||
if (task instanceof PhotoTask) {
|
||||
verify(photoRepository).save(argThat(photo -> actor.equals(photo.getCompletedBy())));
|
||||
} else if (task instanceof SignatureTask) {
|
||||
verify(signatureRepository).save(argThat(signature -> actor.equals(signature.getCompletedBy())
|
||||
&& "Paket beschädigt".equals(signature.getNote())));
|
||||
} else if (task instanceof BarcodeTask) {
|
||||
verify(barcodeRepository).save(argThat(barcode -> actor.equals(barcode.getCompletedBy())));
|
||||
} else if (task instanceof CommentTask) {
|
||||
verify(commentRepository).save(argThat(comment -> actor.equals(comment.getCompletedBy())));
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void optionalTasksOfClosedStationCannotPersistDataOrSendEmail(BaseTask task) {
|
||||
task.setId(new ObjectId());
|
||||
task.setOptional(true);
|
||||
task.setStationOrder(0);
|
||||
Job job = new Job();
|
||||
job.setCompletedStationOrders(List.of(0));
|
||||
rejectCompletion(task, job);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void completedJobRejectsTasksEvenWithoutStationConfirmation(BaseTask task) {
|
||||
task.setId(new ObjectId());
|
||||
task.setOptional(true);
|
||||
Job job = new Job();
|
||||
job.setStatus(JobStatus.COMPLETED);
|
||||
rejectCompletion(task, job);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void repeatedCompletionCannotAddAttachmentsOrSendAnotherEmail(BaseTask task) {
|
||||
task.setId(new ObjectId());
|
||||
task.setCompleted(true);
|
||||
rejectCompletion(task, new Job());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void closedStationIsResolvedByIdEvenWhenTaskOrderIsStale(BaseTask task) {
|
||||
task.setId(new ObjectId());
|
||||
task.setStationId(new ObjectId());
|
||||
task.setStationOrder(7);
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setStationId(task.getStationId());
|
||||
station.setStationOrder(0);
|
||||
Job job = new Job();
|
||||
job.setDeliveryStations(List.of(station));
|
||||
job.setCompletedStationOrders(List.of(0));
|
||||
rejectCompletion(task, job);
|
||||
}
|
||||
|
||||
private void rejectCompletion(BaseTask task, Job job) {
|
||||
when(taskRepository.findById(task.getId())).thenReturn(Optional.of(task));
|
||||
when(taskAssignmentService.findJobForTask(task)).thenReturn(Optional.of(job));
|
||||
Map<String, Object> extra = Map.of("photos", List.of("image-data"), "barcodes", List.of("CODE-1"),
|
||||
"signatureSvg", "<svg/>", "commentText", "Late comment");
|
||||
controller.handleTaskCompleted(Map.of("taskId", task.getIdAsString(), "extraData", extra),
|
||||
task.getTaskType(), "driver-1");
|
||||
verify(taskRepository, never()).save(any());
|
||||
verifyNoInteractions(photoRepository, signatureRepository, barcodeRepository, commentRepository,
|
||||
emailService, jobHistoryService, jobUpdateBroadcaster);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.assecutor.votianlt.pages.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OrderNumberTest {
|
||||
@Mock
|
||||
private JobRepository jobRepository;
|
||||
@InjectMocks
|
||||
private AddJobService service;
|
||||
|
||||
@Test
|
||||
void newOrdersUseTheNewPrefixWithoutRenamingExistingNumbers() {
|
||||
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
Job legacy = new Job();
|
||||
legacy.setJobNumber("JOB" + date + "001");
|
||||
when(jobRepository.findAll()).thenReturn(List.of(legacy));
|
||||
|
||||
String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber");
|
||||
|
||||
assertThat(number).isEqualTo("AUFTRAG" + date + "001");
|
||||
assertThat(legacy.getJobNumber()).isEqualTo("JOB" + date + "001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingOrderNumbersAreSkipped() {
|
||||
String prefix = "AUFTRAG" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
|
||||
Job existing = new Job();
|
||||
existing.setJobNumber(prefix + "002");
|
||||
when(jobRepository.findAll()).thenReturn(List.of(existing));
|
||||
when(jobRepository.existsByJobNumber(prefix + "002")).thenReturn(true);
|
||||
|
||||
String number = ReflectionTestUtils.invokeMethod(service, "generateJobNumber");
|
||||
|
||||
assertThat(number).isEqualTo(prefix + "003");
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.assecutor.votianlt.pages.view;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.vaadin.flow.component.html.Div;
|
||||
import de.assecutor.votianlt.model.task.BaseTask;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
class JobSummaryTaskDescriptionTest {
|
||||
static Stream<BaseTask> tasks() {
|
||||
return Stream.of(new ConfirmationTask("OK"), new SignatureTask());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("tasks")
|
||||
void completedTaskStillShowsInstructionsInCardAndStationSummary(BaseTask task) {
|
||||
JobSummaryView view = new JobSummaryView(null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null);
|
||||
task.setDescription("Am Seiteneingang klingeln.\nBeim Empfang abgeben.");
|
||||
task.setCompleted(true);
|
||||
|
||||
Div card = ReflectionTestUtils.invokeMethod(view, "createTaskCard", task, task.getDisplayName());
|
||||
String summary = ReflectionTestUtils.invokeMethod(view, "summarizeDeliveryTask", task);
|
||||
|
||||
assertThat(card.getElement().getTextRecursively()).contains(task.getDescription());
|
||||
assertThat(summary).contains(task.getDescription());
|
||||
}
|
||||
}
|
||||
+73
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.UserInvoiceData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CustomerInvoiceTemplateVariablesTest {
|
||||
@Test
|
||||
void usesCustomerProfileAndBillingDataIncludingMultilineText() {
|
||||
User user = new User();
|
||||
user.setCompany("Müller & Söhne");
|
||||
user.setStreet("Hafenstraße");
|
||||
user.setHouseNumber("12");
|
||||
user.setZip("20457");
|
||||
user.setCity("Hamburg");
|
||||
user.setEmail("rechnung@example.com");
|
||||
user.setPhone("040 12345");
|
||||
UserInvoiceData billing = new UserInvoiceData();
|
||||
billing.setUstId("DE123456789");
|
||||
billing.setTaxNumber("12/345/67890");
|
||||
billing.setBankName("Musterbank");
|
||||
billing.setIban("DE89 3704 0044 0532 0130 00");
|
||||
billing.setIntroText("Vielen Dank für Ihren Auftrag.\nWir berechnen folgende Leistungen:");
|
||||
billing.setPaymentTerms("Bitte binnen 14 Tagen zahlen.");
|
||||
|
||||
var values = CustomerInvoiceTemplateVariables.create(user, billing, "RE-2026-0001");
|
||||
|
||||
assertThat(values).containsEntry("masterdata.company_name", "Müller & Söhne")
|
||||
.containsEntry("masterdata.sender_line", "Müller & Söhne · Hafenstraße 12 · 20457 Hamburg")
|
||||
.containsEntry("masterdata.contact_details", "rechnung@example.com\n040 12345")
|
||||
.containsEntry("masterdata.tax_details", "USt-IdNr.: DE123456789\nSteuernummer: 12/345/67890")
|
||||
.containsEntry("masterdata.bank_details", "Musterbank\nIBAN: DE89 3704 0044 0532 0130 00")
|
||||
.containsEntry("masterdata.intro", billing.getIntroText())
|
||||
.containsEntry("masterdata.payment_terms", billing.getPaymentTerms())
|
||||
.containsEntry("masterdata.invoice_number", "RE-2026-0001");
|
||||
assertThat(values.get("masterdata.invoice_date")).matches("\\d{2}\\.\\d{2}\\.\\d{4}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentBillingDataDoesNotInventTaxOrBankDetails() {
|
||||
User user = new User();
|
||||
user.setFirstname("Erika");
|
||||
user.setName("Beispiel");
|
||||
|
||||
var values = CustomerInvoiceTemplateVariables.create(user, null, "RE-1");
|
||||
|
||||
assertThat(values).containsEntry("masterdata.company_name", "Erika Beispiel")
|
||||
.containsEntry("masterdata.tax_details", "").containsEntry("masterdata.bank_details", "")
|
||||
.containsEntry("masterdata.contact_details", "")
|
||||
.containsEntry("masterdata.intro", "Wir berechnen Ihnen folgende Leistungen:")
|
||||
.containsEntry("masterdata.payment_terms", "Bitte geben Sie bei der Zahlung die Rechnungsnummer an.");
|
||||
assertThat(values.values()).noneMatch(value -> value.contains("null") || value.contains("Assecutor"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -10,7 +11,6 @@ import static org.mockito.Mockito.when;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobStatus;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.BarcodeRepository;
|
||||
@@ -90,8 +90,6 @@ class DemoModeServiceTest {
|
||||
private ArgumentCaptor<List<Job>> jobsCaptor;
|
||||
@Captor
|
||||
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
|
||||
@Captor
|
||||
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
|
||||
|
||||
private DemoSessionRegistry demoSessionRegistry;
|
||||
private DemoModeService demoModeService;
|
||||
@@ -127,17 +125,8 @@ class DemoModeServiceTest {
|
||||
assertThat(prepared).isTrue();
|
||||
verify(serviceRepository).saveAll(servicesCaptor.capture());
|
||||
assertThat(servicesCaptor.getValue()).hasSize(2);
|
||||
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture());
|
||||
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString());
|
||||
assertThat(invoiceTemplateCaptor.getValue().getTemplateData())
|
||||
.contains("elements")
|
||||
.contains("services.list")
|
||||
.contains("invoice.gross_total")
|
||||
.contains("frei angepasst werden")
|
||||
.contains("textAlign")
|
||||
.contains("center")
|
||||
.contains("fontSize")
|
||||
.contains("20");
|
||||
// Demo customers use the same neutral default as all other customers.
|
||||
verify(invoiceTemplateRepository, never()).save(any());
|
||||
verify(jobRepository).saveAll(jobsCaptor.capture());
|
||||
assertThat(jobsCaptor.getValue()).hasSize(2);
|
||||
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.assecutor.votianlt.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.DeliveryStation;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
@@ -18,6 +20,9 @@ import org.bson.types.ObjectId;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.Mock;
|
||||
@@ -55,7 +60,7 @@ class EmailServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
|
||||
void sendTaskCompletionNotificationIncludesDescriptionAndAssignedAppUserName() {
|
||||
User webUser = createWebUser();
|
||||
AppUser assignedAppUser = createAssignedAppUser();
|
||||
Job job = createJob(webUser, assignedAppUser);
|
||||
@@ -64,13 +69,36 @@ class EmailServiceTest {
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
|
||||
|
||||
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
|
||||
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored",
|
||||
" Ware am Seiteneingang fotografieren.\nAuch die Verpackung dokumentieren. ");
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
String body = mailCaptor.getValue().getText();
|
||||
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
|
||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||
assertThat(body).contains("Auftrag: ").doesNotContain("Job:");
|
||||
assertThat(body).contains("Aufgabe: Foto-Aufgabe\nBeschreibung: Ware am Seiteneingang fotografieren.\n"
|
||||
+ "Auch die Verpackung dokumentieren.\nAbgeschlossen von: Max Mustermann");
|
||||
assertThat(mailCaptor.getValue().getSubject()).isEqualTo("Aufgabe abgeschlossen - JOB-2026-001");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = { " \n\t " })
|
||||
void taskCompletionEmailWithoutDescriptionStillSends(String description) {
|
||||
User webUser = createWebUser();
|
||||
AppUser assignedAppUser = createAssignedAppUser();
|
||||
Job job = createJob(webUser, assignedAppUser);
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
|
||||
|
||||
emailService.sendTaskCompletionNotification(job.getId(), "CONFIRMATION", "task-1", "ignored", description);
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
assertThat(mailCaptor.getValue().getText()).contains("Aufgabe: Bestätigung\nAbgeschlossen von: Max Mustermann")
|
||||
.doesNotContain("Beschreibung:", "null");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -93,6 +121,86 @@ class EmailServiceTest {
|
||||
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
|
||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||
assertThat(mailCaptor.getValue().getSubject()).startsWith("Auftrag abgeschlossen - ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void creationEmailUsesOrderTerminology() {
|
||||
User webUser = createWebUser();
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
|
||||
emailService.sendJobCreationNotification(job.getId(), webUser.getId().toHexString());
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
assertThat(mailCaptor.getValue().getSubject()).startsWith("Neuer Auftrag erstellt - ");
|
||||
assertThat(mailCaptor.getValue().getText()).contains("ein neuer Auftrag", "Auftrag: ")
|
||||
.doesNotContain("neuer Job", "Job:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stationEmailContainsStationAddressAndActualCompletingAppUser() {
|
||||
User webUser = createWebUser();
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
job.setJobNumber("AUFTRAG20260929001");
|
||||
DeliveryStation station = createStation();
|
||||
job.setDeliveryStations(List.of(station));
|
||||
AppUser completingUser = createAssignedAppUser();
|
||||
completingUser.setVorname("Erika");
|
||||
completingUser.setNachname("Musterfrau");
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
when(appUserRepository.findById(completingUser.getId())).thenReturn(Optional.of(completingUser));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, completingUser.getId().toHexString());
|
||||
|
||||
verify(mailSender).send(mailCaptor.capture());
|
||||
SimpleMailMessage mail = mailCaptor.getValue();
|
||||
assertThat(mail.getTo()).containsExactly("anna@example.com");
|
||||
assertThat(mail.getFrom()).isEqualTo("noreply@example.com");
|
||||
assertThat(mail.getSubject()).isEqualTo("Station abgeschlossen - AUFTRAG20260929001 - Station 2");
|
||||
assertThat(mail.getText()).contains("Hallo Dr. Anna Unternehmer,", "Auftrag: AUFTRAG20260929001",
|
||||
"Station: 2", "Empfänger GmbH", "Petra Beispiel", "Hafenstraße 12", "Tor 3", "20457 Hamburg",
|
||||
"Abgeschlossen von: Erika Musterfrau").doesNotContain("null", "Max Mustermann", "Job:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingStationDoesNotSendStationEmail() {
|
||||
Job job = createJob(createWebUser(), createAssignedAppUser());
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1");
|
||||
|
||||
verifyNoInteractions(mailSender, userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRecipientAddressDoesNotSendStationEmail() {
|
||||
User webUser = createWebUser();
|
||||
webUser.setEmail(" ");
|
||||
Job job = createJob(webUser, createAssignedAppUser());
|
||||
job.setDeliveryStations(List.of(createStation()));
|
||||
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||
|
||||
emailService.sendStationCompletionNotification(job.getId(), 1, "driver-1");
|
||||
|
||||
verifyNoInteractions(mailSender);
|
||||
}
|
||||
|
||||
private DeliveryStation createStation() {
|
||||
DeliveryStation station = new DeliveryStation();
|
||||
station.setStationOrder(1);
|
||||
station.setCompany("Empfänger GmbH");
|
||||
station.setFirstName("Petra");
|
||||
station.setLastName("Beispiel");
|
||||
station.setStreet("Hafenstraße");
|
||||
station.setHouseNumber("12");
|
||||
station.setAddressAddition("Tor 3");
|
||||
station.setZip("20457");
|
||||
station.setCity("Hamburg");
|
||||
return station;
|
||||
}
|
||||
|
||||
private User createWebUser() {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InvoiceTemplateServiceTest {
|
||||
@Mock
|
||||
private InvoiceTemplateRepository repository;
|
||||
private InvoiceTemplateService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InvoiceTemplateService(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingCustomerTemplateUsesEditableDefaultWithoutWritingToDatabase() throws Exception {
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
|
||||
|
||||
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
|
||||
|
||||
assertThat(result.getUserId()).isEqualTo("customer-1");
|
||||
assertThat(result.getName()).isEqualTo("Neutral");
|
||||
assertThat(result.getId()).isNull();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
var template = mapper.readTree(mapper.readValue(result.getTemplateData(), String.class));
|
||||
assertThat(template.path("elements").size()).isGreaterThan(10);
|
||||
assertThat(template.toString()).contains("masterdata.company_name", "customer.company_name", "services.list",
|
||||
"masterdata.bank_details", "masterdata.tax_details").doesNotContain("Assecutor", "HHA", "DE6720");
|
||||
verify(repository).findByUserId("customer-1");
|
||||
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void personalTemplateTakesPrecedence() {
|
||||
InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Mein Briefpapier",
|
||||
"{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}");
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal));
|
||||
|
||||
assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal);
|
||||
verify(repository).findByUserId("customer-1");
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullAndEmptySource
|
||||
@ValueSource(strings = { " ", "null", "{}", "{\"elements\":[]}", "\"{\\\"elements\\\":[]}\"", "invalid JSON" })
|
||||
void unusableSavedTemplateFallsBackWithoutOverwritingIt(String data) {
|
||||
InvoiceTemplate existing = new InvoiceTemplate("customer-1", "Standard", data);
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(existing));
|
||||
|
||||
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
|
||||
|
||||
assertThat(result.getTemplateData()).contains("neutral-company", "services.list");
|
||||
assertThat(existing.getTemplateData()).isEqualTo(data);
|
||||
verify(repository).findByUserId("customer-1");
|
||||
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonEncodedPersonalTemplateTakesPrecedence() throws Exception {
|
||||
InvoiceTemplate personal = new InvoiceTemplate("customer-1", "Standard", new ObjectMapper()
|
||||
.writeValueAsString("{\"elements\":[{\"type\":\"text\",\"text\":\"Eigene Vorlage\"}]}"));
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.of(personal));
|
||||
|
||||
assertThat(service.getCustomerTemplateByUserId("customer-1")).isSameAs(personal);
|
||||
}
|
||||
|
||||
@Test
|
||||
void systemTemplateDoesNotFallBackToCustomerDefault() {
|
||||
when(repository.findByUserId("SYSTEM")).thenReturn(Optional.empty());
|
||||
|
||||
assertThat(service.getTemplateByUserId("SYSTEM")).isEmpty();
|
||||
assertThatThrownBy(() -> service.getCustomerTemplateByUserId("SYSTEM"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> service.getCustomerTemplateByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedDefaultIsLoadedAsAnUnsavedCustomerTemplate() {
|
||||
InvoiceTemplate shared = new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral",
|
||||
"{\"elements\":[{\"type\":\"text\",\"text\":\"Gemeinsamer Standard\"}]}");
|
||||
shared.setId("shared-template-id");
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
|
||||
when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID)).thenReturn(Optional.of(shared));
|
||||
|
||||
InvoiceTemplate result = service.getCustomerTemplateByUserId("customer-1");
|
||||
|
||||
assertThat(result.getUserId()).isEqualTo("customer-1");
|
||||
assertThat(result.getId()).isNull();
|
||||
assertThat(result.getTemplateData()).isEqualTo(shared.getTemplateData());
|
||||
assertThat(shared.getUserId()).isEqualTo(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
|
||||
verify(repository).findByUserId("customer-1");
|
||||
verify(repository).findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID);
|
||||
verifyNoMoreInteractions(repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSharedDefaultUsesBundledNeutralTemplate() {
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
|
||||
when(repository.findByUserId(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID))
|
||||
.thenReturn(Optional.of(new InvoiceTemplate(InvoiceTemplateService.CUSTOMER_DEFAULT_USER_ID, "Neutral", "{}")));
|
||||
|
||||
assertThat(service.getCustomerTemplateByUserId("customer-1").getTemplateData()).contains("neutral-company");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCanBeSavedAsPersonalTemplate() {
|
||||
when(repository.findByUserId("customer-1")).thenReturn(Optional.empty());
|
||||
String templateData = service.getCustomerTemplateByUserId("customer-1").getTemplateData();
|
||||
var saved = org.mockito.ArgumentCaptor.forClass(InvoiceTemplate.class);
|
||||
|
||||
service.saveTemplate("customer-1", templateData);
|
||||
|
||||
verify(repository).save(saved.capture());
|
||||
assertThat(saved.getValue().getUserId()).isEqualTo("customer-1");
|
||||
assertThat(saved.getValue().getTemplateData()).isEqualTo(templateData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package de.assecutor.votianlt.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.assecutor.votianlt.model.AppUser;
|
||||
import de.assecutor.votianlt.model.Job;
|
||||
import de.assecutor.votianlt.model.JobHistory;
|
||||
import de.assecutor.votianlt.model.JobHistoryType;
|
||||
import de.assecutor.votianlt.model.User;
|
||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||
import de.assecutor.votianlt.repository.JobHistoryRepository;
|
||||
import de.assecutor.votianlt.repository.JobRepository;
|
||||
import de.assecutor.votianlt.repository.TaskRepository;
|
||||
import de.assecutor.votianlt.repository.UserRepository;
|
||||
import java.util.Optional;
|
||||
import org.bson.types.ObjectId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JobHistoryServiceTest {
|
||||
@Mock
|
||||
private JobHistoryRepository historyRepository;
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
@Mock
|
||||
private AppUserRepository appUserRepository;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private TaskRepository taskRepository;
|
||||
@Mock
|
||||
private JobRepository jobRepository;
|
||||
@InjectMocks
|
||||
private JobHistoryService service;
|
||||
|
||||
@Test
|
||||
void completionKeepsTheActorNameAndTaskDescriptionAsASnapshot() {
|
||||
AppUser driver = driver();
|
||||
ObjectId taskId = new ObjectId();
|
||||
when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver));
|
||||
|
||||
service.logTaskCompletion(new ObjectId(), "CONFIRMATION", taskId.toHexString(), driver.getIdAsString(),
|
||||
"Bestätigung", "Bestätigung durchgeführt", "Ware am Seiteneingang abgeben.\nBeim Pförtner melden.");
|
||||
|
||||
ArgumentCaptor<JobHistory> history = ArgumentCaptor.forClass(JobHistory.class);
|
||||
verify(historyRepository).save(history.capture());
|
||||
JobHistory entry = history.getValue();
|
||||
assertThat(entry.getChangedBy()).isEqualTo("Max Mustermann");
|
||||
assertThat(entry.getTaskId()).isEqualTo(taskId);
|
||||
assertThat(service.getTaskDescription(entry))
|
||||
.isEqualTo("Ware am Seiteneingang abgeben.\nBeim Pförtner melden.");
|
||||
verifyNoInteractions(taskRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyHistoryResolvesDescriptionAndRecordedActorFromTheTask() {
|
||||
AppUser driver = driver();
|
||||
ObjectId taskId = new ObjectId();
|
||||
ConfirmationTask task = new ConfirmationTask("OK");
|
||||
task.setDescription("Bitte beim Empfang unterschreiben lassen.");
|
||||
task.setCompletedBy(driver.getIdAsString());
|
||||
JobHistory entry = legacyEntry(taskId);
|
||||
when(taskRepository.findById(taskId)).thenReturn(Optional.of(task));
|
||||
when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver));
|
||||
|
||||
assertThat(service.getTaskDescription(entry)).isEqualTo(task.getDescription());
|
||||
assertThat(service.getCreatedByName(entry)).isEqualTo("Max Mustermann");
|
||||
verifyNoInteractions(jobRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyAssigneeFallbackIsClearlyIdentified() {
|
||||
AppUser driver = driver();
|
||||
JobHistory entry = legacyEntry(new ObjectId());
|
||||
entry.setChangedBy("Unkown");
|
||||
Job job = new Job();
|
||||
job.setAppUser(driver.getIdAsString());
|
||||
when(jobRepository.findById(entry.getJobId())).thenReturn(Optional.of(job));
|
||||
when(appUserRepository.findById(driver.getId())).thenReturn(Optional.of(driver));
|
||||
|
||||
assertThat(service.getCreatedByName(entry)).isEqualTo("Max Mustermann (zugewiesener App-Nutzer)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyDescriptionSnapshotDoesNotPickUpLaterTaskEdits() {
|
||||
JobHistory entry = legacyEntry(new ObjectId());
|
||||
entry.setTaskDescription("");
|
||||
|
||||
assertThat(service.getTaskDescription(entry)).isEmpty();
|
||||
verifyNoInteractions(taskRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void webUserIdsAreDisplayedAsNames() {
|
||||
User user = new User();
|
||||
user.setId(new ObjectId());
|
||||
user.setFirstname("Anna");
|
||||
user.setName("Unternehmer");
|
||||
when(userRepository.findById(user.getId())).thenReturn(Optional.of(user));
|
||||
JobHistory entry = new JobHistory();
|
||||
entry.setChangedBy(user.getId().toHexString());
|
||||
|
||||
assertThat(service.getCreatedByName(entry)).isEqualTo("Anna Unternehmer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingLegacyTaskIsHandledWithoutInventingAnAuthor() {
|
||||
JobHistory entry = new JobHistory();
|
||||
entry.setChangeType(JobHistoryType.TASK_COMPLETED);
|
||||
entry.setDetails("Task-ID: invalid, Task-Typ: PHOTO");
|
||||
entry.setChangedBy("Unknown");
|
||||
|
||||
assertThat(service.getTaskDescription(entry)).isEmpty();
|
||||
assertThat(service.getCreatedByName(entry)).isEqualTo("Unbekannt");
|
||||
verifyNoInteractions(taskRepository, appUserRepository, jobRepository);
|
||||
}
|
||||
|
||||
private JobHistory legacyEntry(ObjectId taskId) {
|
||||
JobHistory entry = new JobHistory();
|
||||
entry.setJobId(new ObjectId());
|
||||
entry.setChangeType(JobHistoryType.TASK_COMPLETED);
|
||||
entry.setChangedBy("Unknown");
|
||||
entry.setDetails("Task-ID: " + taskId.toHexString() + ", Task-Typ: CONFIRMATION");
|
||||
return entry;
|
||||
}
|
||||
|
||||
private AppUser driver() {
|
||||
AppUser driver = new AppUser();
|
||||
driver.setId(new ObjectId());
|
||||
driver.setVorname("Max");
|
||||
driver.setNachname("Mustermann");
|
||||
return driver;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user