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:flutter/material.dart';
|
||||||
|
import 'package:votianlt_app/models/cargo_item.dart';
|
||||||
|
|
||||||
import 'app_theme.dart';
|
import 'app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'l10n/localization_helpers.dart';
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'models/delivery_station.dart';
|
import 'models/delivery_station.dart';
|
||||||
import 'models/job.dart';
|
import 'models/job.dart';
|
||||||
|
import 'models/job_progress.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
import 'task_view.dart';
|
import 'task_view.dart';
|
||||||
import 'widgets/offline_banner.dart';
|
import 'widgets/offline_banner.dart';
|
||||||
@@ -12,8 +14,10 @@ import 'widgets/offline_banner.dart';
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Color? deliveryStationCardBackgroundColor(
|
Color? deliveryStationCardBackgroundColor(
|
||||||
DeliveryStation station,
|
DeliveryStation station,
|
||||||
Map<String, bool> taskStatuses,
|
Map<String, bool> taskStatuses, {
|
||||||
) {
|
bool isClosed = false,
|
||||||
|
}) {
|
||||||
|
if (isClosed) return AppColors.successSoft;
|
||||||
if (station.tasks.isEmpty) {
|
if (station.tasks.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -36,6 +40,7 @@ class CargoItemsView extends StatefulWidget {
|
|||||||
class _CargoItemsViewState extends State<CargoItemsView> {
|
class _CargoItemsViewState extends State<CargoItemsView> {
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
Map<String, bool> _taskStatuses = const {};
|
Map<String, bool> _taskStatuses = const {};
|
||||||
|
Set<int> _completedStations = const {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -45,9 +50,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
|
|
||||||
Future<void> _loadLocalTaskStatuses() async {
|
Future<void> _loadLocalTaskStatuses() async {
|
||||||
final map = await _databaseService.loadAllTaskStatuses();
|
final map = await _databaseService.loadAllTaskStatuses();
|
||||||
|
final completedStations = await _databaseService.loadCompletedStationOrders(
|
||||||
|
widget.job,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_taskStatuses = map;
|
_taskStatuses = map;
|
||||||
|
_completedStations = completedStations;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +90,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: ListView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// Job summary card
|
// Job summary card
|
||||||
Card(
|
Card(
|
||||||
@@ -122,11 +130,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: Colors.green[600],
|
color: Colors.green[600],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Flexible(
|
||||||
widget.job.pickupCity,
|
child: Text(
|
||||||
style: TextStyle(
|
widget.job.pickupCity,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
color: Colors.grey[700],
|
fontSize: 12,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -142,13 +152,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Flexible(
|
||||||
widget.job.deliveryCitiesDisplay.isNotEmpty
|
child: Text(
|
||||||
? widget.job.deliveryCitiesDisplay
|
widget.job.deliveryCitiesDisplay.isNotEmpty
|
||||||
: widget.job.deliveryCity,
|
? widget.job.deliveryCitiesDisplay
|
||||||
style: TextStyle(
|
: widget.job.deliveryCity,
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
color: Colors.grey[700],
|
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
|
// Delivery stations section header
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -166,17 +202,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
l10n.deliveryStationsCount(_deliveryStations.length),
|
child: Text(
|
||||||
style: const TextStyle(
|
l10n.deliveryStationsCount(_deliveryStations.length),
|
||||||
fontSize: 20,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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 {
|
List<DeliveryStation> get _deliveryStations {
|
||||||
if (widget.job.deliveryStations.isNotEmpty) {
|
if (widget.job.deliveryStations.isNotEmpty) {
|
||||||
return widget.job.deliveryStations;
|
return widget.job.deliveryStations;
|
||||||
@@ -238,12 +309,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return Column(
|
||||||
itemCount: _deliveryStations.length,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
itemBuilder: (context, index) {
|
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
|
||||||
final station = _deliveryStations[index];
|
|
||||||
return _buildDeliveryStationCard(station);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +319,11 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
|||||||
final backgroundColor = deliveryStationCardBackgroundColor(
|
final backgroundColor = deliveryStationCardBackgroundColor(
|
||||||
station,
|
station,
|
||||||
_taskStatuses,
|
_taskStatuses,
|
||||||
|
isClosed: JobProgress(
|
||||||
|
widget.job,
|
||||||
|
taskStatuses: _taskStatuses,
|
||||||
|
localCompletedStations: _completedStations,
|
||||||
|
).isStationClosed(station.stationOrder),
|
||||||
);
|
);
|
||||||
final title =
|
final title =
|
||||||
station.displayName.isNotEmpty ? station.displayName : station.company;
|
station.displayName.isNotEmpty ? station.displayName : station.company;
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
title: Text(() {
|
title: Text(() {
|
||||||
if (isJobChat) {
|
if (isJobChat) {
|
||||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||||
return 'Job $jobNumber';
|
return 'Auftrag $jobNumber';
|
||||||
}
|
}
|
||||||
if (showJobId) {
|
if (showJobId) {
|
||||||
return 'Job $jobId';
|
return 'Auftrag $jobId';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return localizedChatTitle(context, chat);
|
return localizedChatTitle(context, chat);
|
||||||
@@ -154,7 +154,7 @@ class _ChatsViewState extends State<ChatsView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
isJobChat ? 'JOB' : 'ALLG',
|
isJobChat ? 'AUFTRAG' : 'ALLG',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
+56
-44
@@ -8,6 +8,7 @@ import 'services/dart_mq.dart';
|
|||||||
import 'services/chat_service.dart';
|
import 'services/chat_service.dart';
|
||||||
import 'models/delivery_station.dart';
|
import 'models/delivery_station.dart';
|
||||||
import 'models/job.dart';
|
import 'models/job.dart';
|
||||||
|
import 'models/job_progress.dart';
|
||||||
import 'models/task.dart';
|
import 'models/task.dart';
|
||||||
import 'models/tasks/confirmation_task.dart';
|
import 'models/tasks/confirmation_task.dart';
|
||||||
import 'models/tasks/photo_task.dart';
|
import 'models/tasks/photo_task.dart';
|
||||||
@@ -23,6 +24,13 @@ import 'services/database_service.dart';
|
|||||||
import 'navigation_observer.dart';
|
import 'navigation_observer.dart';
|
||||||
import 'routing_view.dart';
|
import 'routing_view.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Color? jobCardBackgroundColor(JobProgress progress) {
|
||||||
|
if (progress.isCompleted) return AppColors.successSoft;
|
||||||
|
if (progress.hasProgress) return AppColors.warningSoft;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
class JobsView extends StatefulWidget {
|
class JobsView extends StatefulWidget {
|
||||||
const JobsView({super.key});
|
const JobsView({super.key});
|
||||||
|
|
||||||
@@ -63,6 +71,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
bool _isLoggingOut = false;
|
bool _isLoggingOut = false;
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
Map<String, bool> _taskStatuses = const {};
|
Map<String, bool> _taskStatuses = const {};
|
||||||
|
Map<String, Set<int>> _completedStations = const {};
|
||||||
Map<String, bool> _jobSeen = const {};
|
Map<String, bool> _jobSeen = const {};
|
||||||
Map<String, double> _jobSwipeOffsets = const {};
|
Map<String, double> _jobSwipeOffsets = const {};
|
||||||
String? _openJobId;
|
String? _openJobId;
|
||||||
@@ -145,7 +154,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
final message =
|
final message =
|
||||||
jobNumber != null
|
jobNumber != null
|
||||||
? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
? 'Auftrag $jobNumber ${AppLocalizations.of(context).jobRemoved}'
|
||||||
: AppLocalizations.of(context).jobRemoved;
|
: AppLocalizations.of(context).jobRemoved;
|
||||||
_showSnack(message, backgroundColor: AppColors.warning);
|
_showSnack(message, backgroundColor: AppColors.warning);
|
||||||
}
|
}
|
||||||
@@ -479,9 +488,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
|
|
||||||
Future<void> _loadLocalTaskStatuses() async {
|
Future<void> _loadLocalTaskStatuses() async {
|
||||||
final map = await _databaseService.loadAllTaskStatuses();
|
final map = await _databaseService.loadAllTaskStatuses();
|
||||||
|
final completedStations = <String, Set<int>>{};
|
||||||
|
for (final job in _appState.assignedJobs) {
|
||||||
|
completedStations[job.id] = await _databaseService
|
||||||
|
.loadCompletedStationOrders(job);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_taskStatuses = map;
|
_taskStatuses = map;
|
||||||
|
_completedStations = completedStations;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,25 +965,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
statusColor = AppColors.textMuted;
|
statusColor = AppColors.textMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine card background color based on task completion
|
final progress = JobProgress(
|
||||||
|
job,
|
||||||
|
taskStatuses: _taskStatuses,
|
||||||
|
localCompletedStations: _completedStations[job.id] ?? const {},
|
||||||
|
);
|
||||||
final totalTasks = job.tasks.length;
|
final totalTasks = job.tasks.length;
|
||||||
int completedTasks = 0;
|
final completedTasks = progress.completedTaskCount;
|
||||||
for (final t in job.tasks) {
|
final isJobCompleted = progress.isCompleted;
|
||||||
final isCompleted = _taskStatuses[t.id] ?? t.completed;
|
final cardBg = jobCardBackgroundColor(progress);
|
||||||
if (isCompleted) completedTasks++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all tasks are completed (job is done)
|
|
||||||
final bool isJobCompleted = totalTasks > 0 && completedTasks == totalTasks;
|
|
||||||
|
|
||||||
Color? cardBg;
|
|
||||||
if (totalTasks == 0 || completedTasks == 0) {
|
|
||||||
cardBg = null; // unchanged (default)
|
|
||||||
} else if (completedTasks > 0 && completedTasks < totalTasks) {
|
|
||||||
cardBg = AppColors.warningSoft;
|
|
||||||
} else if (completedTasks == totalTasks) {
|
|
||||||
cardBg = AppColors.successSoft;
|
|
||||||
}
|
|
||||||
// Build robust display strings with fallbacks
|
// Build robust display strings with fallbacks
|
||||||
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]);
|
||||||
final pickupDisplayName =
|
final pickupDisplayName =
|
||||||
@@ -1211,7 +1216,17 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Progress bar for tasks
|
if (isJobCompleted) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).jobCompleted,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.success,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Progress bar for tasks (optional tasks retain their actual state).
|
||||||
if (totalTasks > 0) ...[
|
if (totalTasks > 0) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -1235,7 +1250,8 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
backgroundColor: AppColors.border,
|
backgroundColor: AppColors.border,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
completedTasks >= totalTasks
|
isJobCompleted ||
|
||||||
|
completedTasks >= totalTasks
|
||||||
? AppColors.success
|
? AppColors.success
|
||||||
: (completedTasks > 0
|
: (completedTasks > 0
|
||||||
? AppColors.warning
|
? AppColors.warning
|
||||||
@@ -1406,7 +1422,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Dates and Price
|
// Creation date
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
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) {
|
void _toggleTaskCompletion(Job job, int taskIndex) {
|
||||||
|
final progress = JobProgress(
|
||||||
|
job,
|
||||||
|
taskStatuses: _taskStatuses,
|
||||||
|
localCompletedStations: _completedStations[job.id] ?? const {},
|
||||||
|
);
|
||||||
|
if (progress.isStationClosed(job.tasks[taskIndex].stationOrder ?? 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Create a new task with toggled completion status
|
// Create a new task with toggled completion status
|
||||||
final updatedTask = job.tasks[taskIndex].copyWith(
|
final updatedTask = job.tasks[taskIndex].copyWith(
|
||||||
completed: !job.tasks[taskIndex].completed,
|
completed: !job.tasks[taskIndex].completed,
|
||||||
@@ -1509,6 +1512,7 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
id: job.id,
|
id: job.id,
|
||||||
jobNumber: job.jobNumber,
|
jobNumber: job.jobNumber,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
|
completedStationOrders: job.completedStationOrders,
|
||||||
createdAt: job.createdAt,
|
createdAt: job.createdAt,
|
||||||
updatedAt: job.updatedAt,
|
updatedAt: job.updatedAt,
|
||||||
createdBy: job.createdBy,
|
createdBy: job.createdBy,
|
||||||
@@ -1711,7 +1715,15 @@ class _JobsViewState extends State<JobsView> with RouteAware {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...job.tasks.asMap().entries.map(
|
...job.tasks.asMap().entries.map(
|
||||||
(entry) => GestureDetector(
|
(entry) => GestureDetector(
|
||||||
onTap: () => _toggleTaskCompletion(job, entry.key),
|
onTap:
|
||||||
|
JobProgress(
|
||||||
|
job,
|
||||||
|
taskStatuses: _taskStatuses,
|
||||||
|
localCompletedStations:
|
||||||
|
_completedStations[job.id] ?? const {},
|
||||||
|
).isStationClosed(entry.value.stationOrder ?? 0)
|
||||||
|
? null
|
||||||
|
: () => _toggleTaskCompletion(job, entry.key),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 4),
|
margin: const EdgeInsets.only(bottom: 4),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
// ==================== NAVIGATION ====================
|
// ==================== NAVIGATION ====================
|
||||||
@override
|
@override
|
||||||
String get jobs => 'Jobs';
|
String get jobs => 'Aufträge';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get availableJobs => 'Auftragsliste';
|
String get availableJobs => 'Auftragsliste';
|
||||||
@@ -155,10 +155,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
// ==================== JOBS ====================
|
// ==================== JOBS ====================
|
||||||
@override
|
@override
|
||||||
String get noJobsAssigned => 'Keine Jobs zugewiesen';
|
String get noJobsAssigned => 'Keine Aufträge zugewiesen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noJobsMessage => 'Ihre zugewiesenen Jobs werden hier angezeigt.';
|
String get noJobsMessage =>
|
||||||
|
'Ihre zugewiesenen Aufträge werden hier angezeigt.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren';
|
String get pullToRefresh => 'Nach unten ziehen zum Aktualisieren';
|
||||||
@@ -206,13 +207,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get dimensions => 'Abmessungen';
|
String get dimensions => 'Abmessungen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobDeleted => 'Job gelöscht';
|
String get jobDeleted => 'Auftrag gelöscht';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobDeleteError => 'Fehler beim Löschen des Jobs';
|
String get jobDeleteError => 'Fehler beim Löschen des Auftrags';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobCompleted => 'Job abgeschlossen';
|
String get jobCompleted => 'Auftrag abgeschlossen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get from => 'Von';
|
String get from => 'Von';
|
||||||
@@ -221,10 +222,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get to => 'nach';
|
String get to => 'nach';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobsUpdated => 'Jobs aktualisiert';
|
String get jobsUpdated => 'Aufträge aktualisiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectionRestored => 'Verbindung wiederhergestellt. Lade Jobs...';
|
String get connectionRestored =>
|
||||||
|
'Verbindung wiederhergestellt. Lade Aufträge...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get connectionLost => 'Verbindung verloren. Offline.';
|
String get connectionLost => 'Verbindung verloren. Offline.';
|
||||||
@@ -233,13 +235,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get offline => 'Offline';
|
String get offline => 'Offline';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get deleteJob => 'Job löschen';
|
String get deleteJob => 'Auftrag löschen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobRemoved => 'wurde entfernt';
|
String get jobRemoved => 'wurde entfernt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newJobReceived => 'Neuer Job erhalten';
|
String get newJobReceived => 'Neuer Auftrag erhalten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobDetails => 'Auftragsdetails';
|
String get jobDetails => 'Auftragsdetails';
|
||||||
@@ -258,7 +260,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDeliveryStationsMessage =>
|
String get noDeliveryStationsMessage =>
|
||||||
'Dieser Job enthält aktuell keine Lieferstationen.';
|
'Dieser Auftrag enthält aktuell keine Lieferstationen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get phone => 'Telefon';
|
String get phone => 'Telefon';
|
||||||
@@ -277,7 +279,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get noTasks => 'Keine Aufgaben';
|
String get noTasks => 'Keine Aufgaben';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noTasksMessage => 'Für diesen Job sind keine Aufgaben definiert.';
|
String get noTasksMessage =>
|
||||||
|
'Für diesen Auftrag sind keine Aufgaben definiert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get taskOrder => 'Reihenfolge';
|
String get taskOrder => 'Reihenfolge';
|
||||||
@@ -498,13 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get imageSendError => 'Bild konnte nicht gesendet werden.';
|
String get imageSendError => 'Bild konnte nicht gesendet werden.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chatTypeJob => 'Job-spezifisch';
|
String get chatTypeJob => 'Auftragsbezogen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get chatTypeGeneral => 'Allgemein';
|
String get chatTypeGeneral => 'Allgemein';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get jobNumber => 'Job-Nummer';
|
String get jobNumber => 'Auftragsnummer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get messages => 'Nachrichten';
|
String get messages => 'Nachrichten';
|
||||||
@@ -558,7 +561,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get noCargoItemsMessage =>
|
String get noCargoItemsMessage =>
|
||||||
'Für diesen Job sind keine Frachtgüter definiert.';
|
'Für diesen Auftrag sind keine Frachtgüter definiert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get article => 'Artikel';
|
String get article => 'Artikel';
|
||||||
|
|||||||
@@ -46,5 +46,12 @@ String localizedChatTitle(BuildContext context, Chat chat) {
|
|||||||
if (chat.type == ChatType.general) {
|
if (chat.type == ChatType.general) {
|
||||||
return AppLocalizations.of(context).generalMessages;
|
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);
|
return localizeKnownText(context, chat.title);
|
||||||
}
|
}
|
||||||
@@ -87,9 +87,18 @@ class CargoItem {
|
|||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
|
|
||||||
|
bool get hasDimensions =>
|
||||||
|
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
|
||||||
|
|
||||||
/// Get formatted dimensions string for display
|
/// Get formatted dimensions string for display
|
||||||
String get formattedDimensions {
|
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
|
/// Get formatted weight string for display
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class Chat {
|
|||||||
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
final lastMessage = messages.isNotEmpty ? messages.last : null;
|
||||||
return Chat(
|
return Chat(
|
||||||
id: id,
|
id: id,
|
||||||
title: 'Job $jobNumber',
|
title: 'Auftrag $jobNumber',
|
||||||
receiver: receiver,
|
receiver: receiver,
|
||||||
type: ChatType.jobSpecific,
|
type: ChatType.jobSpecific,
|
||||||
jobId: jobId,
|
jobId: jobId,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class DeliveryStation {
|
|||||||
final tasks =
|
final tasks =
|
||||||
(json['tasks'] as List? ?? const []).map((rawTask) {
|
(json['tasks'] as List? ?? const []).map((rawTask) {
|
||||||
final taskJson = Map<String, dynamic>.from(rawTask as Map);
|
final taskJson = Map<String, dynamic>.from(rawTask as Map);
|
||||||
taskJson['stationOrder'] ??= stationOrder;
|
taskJson['stationOrder'] = stationOrder;
|
||||||
return Task.fromJson(taskJson);
|
return Task.fromJson(taskJson);
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
|
..sort((a, b) => (a.taskOrder ?? 0).compareTo(b.taskOrder ?? 0));
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,7 @@ class Job {
|
|||||||
final String id; // Will store the timestamp as string
|
final String id; // Will store the timestamp as string
|
||||||
final String jobNumber;
|
final String jobNumber;
|
||||||
final String status;
|
final String status;
|
||||||
|
final List<int> completedStationOrders;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final String createdBy;
|
final String createdBy;
|
||||||
@@ -61,6 +62,7 @@ class Job {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.jobNumber,
|
required this.jobNumber,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.completedStationOrders = const [],
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.createdBy,
|
required this.createdBy,
|
||||||
@@ -109,7 +111,7 @@ class Job {
|
|||||||
this.assignedTo,
|
this.assignedTo,
|
||||||
this.location,
|
this.location,
|
||||||
this.additionalData,
|
this.additionalData,
|
||||||
}) : title = title ?? 'Job $jobNumber',
|
}) : title = title ?? 'Auftrag $jobNumber',
|
||||||
description =
|
description =
|
||||||
description ?? 'Transport von $pickupCity nach $deliveryCity';
|
description ?? 'Transport von $pickupCity nach $deliveryCity';
|
||||||
|
|
||||||
@@ -332,6 +334,12 @@ class Job {
|
|||||||
id: idValue,
|
id: idValue,
|
||||||
jobNumber: jobJson['jobNumber']?.toString() ?? '',
|
jobNumber: jobJson['jobNumber']?.toString() ?? '',
|
||||||
status: jobJson['status']?.toString() ?? 'UNKNOWN',
|
status: jobJson['status']?.toString() ?? 'UNKNOWN',
|
||||||
|
completedStationOrders:
|
||||||
|
(jobJson['completedStationOrders'] as List? ?? const [])
|
||||||
|
.map((value) => int.tryParse(value.toString()))
|
||||||
|
.whereType<int>()
|
||||||
|
.toSet()
|
||||||
|
.toList(),
|
||||||
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
|
createdAt: _parseDateTime(jobJson['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
|
updatedAt: _parseDateTime(jobJson['updatedAt']) ?? DateTime.now(),
|
||||||
createdBy: jobJson['createdBy']?.toString() ?? '',
|
createdBy: jobJson['createdBy']?.toString() ?? '',
|
||||||
@@ -391,6 +399,7 @@ class Job {
|
|||||||
id: id,
|
id: id,
|
||||||
jobNumber: t(jobNumber),
|
jobNumber: t(jobNumber),
|
||||||
status: t(status),
|
status: t(status),
|
||||||
|
completedStationOrders: List.of(completedStationOrders),
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
createdBy: t(createdBy),
|
createdBy: t(createdBy),
|
||||||
@@ -446,6 +455,7 @@ class Job {
|
|||||||
'id': id,
|
'id': id,
|
||||||
'jobNumber': jobNumber,
|
'jobNumber': jobNumber,
|
||||||
'status': status,
|
'status': status,
|
||||||
|
'completedStationOrders': completedStationOrders,
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
'createdBy': createdBy,
|
'createdBy': createdBy,
|
||||||
|
|||||||
@@ -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) {
|
String _buildJobTitle(String? jobNumber, String? jobId) {
|
||||||
if (jobNumber != null && jobNumber.isNotEmpty) {
|
if (jobNumber != null && jobNumber.isNotEmpty) {
|
||||||
return 'Job $jobNumber';
|
return 'Auftrag $jobNumber';
|
||||||
}
|
}
|
||||||
if (jobId != null && jobId.length >= 6) {
|
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(
|
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
|
/// Load all task completion statuses
|
||||||
Future<Map<String, bool>> loadAllTaskStatuses() async {
|
Future<Map<String, bool>> loadAllTaskStatuses() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class NotificationService {
|
|||||||
'Benachrichtigungen bei neuen Chat-Nachrichten';
|
'Benachrichtigungen bei neuen Chat-Nachrichten';
|
||||||
|
|
||||||
static const String _jobChannelId = 'new_jobs';
|
static const String _jobChannelId = 'new_jobs';
|
||||||
static const String _jobChannelName = 'Neue Jobs';
|
static const String _jobChannelName = 'Neue Aufträge';
|
||||||
static const String _jobChannelDescription =
|
static const String _jobChannelDescription =
|
||||||
'Benachrichtigungen bei neuen Job-Zuweisungen';
|
'Benachrichtigungen bei neuen Auftragszuweisungen';
|
||||||
|
|
||||||
int _nextId = 0;
|
int _nextId = 0;
|
||||||
|
|
||||||
@@ -49,12 +49,15 @@ class NotificationService {
|
|||||||
|
|
||||||
await _plugin
|
await _plugin
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>()
|
||||||
?.requestNotificationsPermission();
|
?.requestNotificationsPermission();
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
developer.log('NotificationService initialized',
|
developer.log(
|
||||||
name: 'NotificationService');
|
'NotificationService initialized',
|
||||||
|
name: 'NotificationService',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showChatNotification({
|
Future<void> showChatNotification({
|
||||||
@@ -87,8 +90,13 @@ class NotificationService {
|
|||||||
iOS: iosDetails,
|
iOS: iosDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _plugin.show(_nextId++, title, body, details,
|
await _plugin.show(
|
||||||
payload: 'chat:$conversationKey');
|
_nextId++,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
details,
|
||||||
|
payload: 'chat:$conversationKey',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> showJobNotification({
|
Future<void> showJobNotification({
|
||||||
|
|||||||
@@ -440,11 +440,11 @@ class WebSocketService {
|
|||||||
if (currentJobCount > 0 && jobs.length > currentJobCount) {
|
if (currentJobCount > 0 && jobs.length > currentJobCount) {
|
||||||
final newCount = jobs.length - currentJobCount;
|
final newCount = jobs.length - currentJobCount;
|
||||||
NotificationService().showJobNotification(
|
NotificationService().showJobNotification(
|
||||||
title: 'Neue Jobs',
|
title: 'Neue Aufträge',
|
||||||
body:
|
body:
|
||||||
newCount == 1
|
newCount == 1
|
||||||
? 'Sie haben einen neuen Job erhalten.'
|
? 'Sie haben einen neuen Auftrag erhalten.'
|
||||||
: 'Sie haben $newCount neue Jobs erhalten.',
|
: 'Sie haben $newCount neue Aufträge erhalten.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,11 +597,11 @@ class WebSocketService {
|
|||||||
|
|
||||||
// Show notification with sound for new job
|
// Show notification with sound for new job
|
||||||
NotificationService().showJobNotification(
|
NotificationService().showJobNotification(
|
||||||
title: 'Neuer Job',
|
title: 'Neuer Auftrag',
|
||||||
body:
|
body:
|
||||||
jobNumber != null && jobNumber.isNotEmpty
|
jobNumber != null && jobNumber.isNotEmpty
|
||||||
? 'Job $jobNumber wurde Ihnen zugewiesen.'
|
? 'Auftrag $jobNumber wurde Ihnen zugewiesen.'
|
||||||
: 'Sie haben einen neuen Job erhalten.',
|
: 'Sie haben einen neuen Auftrag erhalten.',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Publish event via DartMQ for UI to handle
|
// Publish event via DartMQ for UI to handle
|
||||||
@@ -642,8 +642,8 @@ class WebSocketService {
|
|||||||
final jobNumber = message.jobNumber ?? '';
|
final jobNumber = message.jobNumber ?? '';
|
||||||
notificationTitle =
|
notificationTitle =
|
||||||
jobNumber.isNotEmpty
|
jobNumber.isNotEmpty
|
||||||
? 'Nachricht zu Job $jobNumber'
|
? 'Nachricht zu Auftrag $jobNumber'
|
||||||
: 'Neue Job-Nachricht';
|
: 'Neue Auftragsnachricht';
|
||||||
} else {
|
} else {
|
||||||
notificationTitle = 'Neue Nachricht';
|
notificationTitle = 'Neue Nachricht';
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1216,7 @@ class WebSocketService {
|
|||||||
name: 'WebSocketService',
|
name: 'WebSocketService',
|
||||||
);
|
);
|
||||||
developer.log('Stack: $st', 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/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'app_theme.dart';
|
import 'app_theme.dart';
|
||||||
|
import 'app_state.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'l10n/localization_helpers.dart';
|
import 'l10n/localization_helpers.dart';
|
||||||
import 'models/job.dart';
|
import 'models/job.dart';
|
||||||
|
import 'models/job_progress.dart';
|
||||||
import 'models/task.dart';
|
import 'models/task.dart';
|
||||||
import 'models/tasks/confirmation_task.dart';
|
import 'models/tasks/confirmation_task.dart';
|
||||||
import 'models/tasks/photo_task.dart';
|
import 'models/tasks/photo_task.dart';
|
||||||
@@ -42,29 +44,86 @@ class TaskView extends StatefulWidget {
|
|||||||
class _TaskViewState extends State<TaskView> {
|
class _TaskViewState extends State<TaskView> {
|
||||||
final Set<String> _completedTasks = {};
|
final Set<String> _completedTasks = {};
|
||||||
final DatabaseService _databaseService = DatabaseService();
|
final DatabaseService _databaseService = DatabaseService();
|
||||||
|
late Job _job;
|
||||||
|
Set<int> _localCompletedStations = {};
|
||||||
|
StreamSubscription<void>? _jobsUpdatedSubscription;
|
||||||
|
bool _statusesLoaded = false;
|
||||||
|
bool _stationCompleted = false;
|
||||||
|
bool _isCompletingStation = false;
|
||||||
// Store SVG representations of signatures per task for later use
|
// Store SVG representations of signatures per task for later use
|
||||||
final Map<String, String> _signatureSvgByTask = {};
|
final Map<String, String> _signatureSvgByTask = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_job = widget.job;
|
||||||
|
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
|
||||||
|
(_) => _loadTaskStatuses(),
|
||||||
|
);
|
||||||
_loadTaskStatuses();
|
_loadTaskStatuses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant TaskView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!identical(oldWidget.job, widget.job) ||
|
||||||
|
oldWidget.stationOrder != widget.stationOrder) {
|
||||||
|
_job = widget.job;
|
||||||
|
_statusesLoaded = false;
|
||||||
|
_loadTaskStatuses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_jobsUpdatedSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
JobProgress get _progress => JobProgress(
|
||||||
|
_job,
|
||||||
|
taskStatuses: {for (final id in _completedTasks) id: true},
|
||||||
|
localCompletedStations: _localCompletedStations,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _canCompleteTask(Task task) =>
|
||||||
|
mounted &&
|
||||||
|
_statusesLoaded &&
|
||||||
|
!_isCompletingStation &&
|
||||||
|
!_stationCompleted &&
|
||||||
|
_progress.canCompleteTask(task);
|
||||||
|
|
||||||
List<Task> get _visibleTasks {
|
List<Task> get _visibleTasks {
|
||||||
final stationOrder = widget.stationOrder;
|
final stationOrder = widget.stationOrder;
|
||||||
if (stationOrder == null) {
|
if (stationOrder == null) {
|
||||||
return widget.job.tasks;
|
return _job.tasks;
|
||||||
}
|
}
|
||||||
return widget.job.tasks
|
return _job.tasks
|
||||||
.where((task) => task.stationOrder == stationOrder)
|
.where((task) => task.stationOrder == stationOrder)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get _stationCompletionKey =>
|
||||||
|
'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
|
||||||
|
|
||||||
/// Load task completion and skipped statuses from database and merge with JSON task states
|
/// Load task completion and skipped statuses from database and merge with JSON task states
|
||||||
Future<void> _loadTaskStatuses() async {
|
Future<void> _loadTaskStatuses() async {
|
||||||
|
var latestJob = _job;
|
||||||
|
for (final savedJob in await _databaseService.loadJobs()) {
|
||||||
|
if (savedJob.id == _job.id &&
|
||||||
|
!savedJob.updatedAt.isBefore(latestJob.updatedAt)) {
|
||||||
|
latestJob = savedJob;
|
||||||
|
}
|
||||||
|
}
|
||||||
final statuses = await _databaseService.loadAllTaskStatuses();
|
final statuses = await _databaseService.loadAllTaskStatuses();
|
||||||
|
final completedStations = await _databaseService.loadCompletedStationOrders(
|
||||||
|
latestJob,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_job = latestJob;
|
||||||
|
_localCompletedStations = completedStations;
|
||||||
|
_statusesLoaded = true;
|
||||||
_completedTasks.clear();
|
_completedTasks.clear();
|
||||||
// 1) Add all completed from DB
|
// 1) Add all completed from DB
|
||||||
for (final entry in statuses.entries) {
|
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
|
// 2) Merge: also mark tasks completed if the job JSON already had them completed
|
||||||
for (final t in widget.job.tasks) {
|
for (final t in _job.tasks) {
|
||||||
if (t.completed) {
|
if (t.completed) {
|
||||||
_completedTasks.add(t.id);
|
_completedTasks.add(t.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +148,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
title: Text(
|
title: Text(
|
||||||
widget.stationTitle?.isNotEmpty == true
|
widget.stationTitle?.isNotEmpty == true
|
||||||
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
|
||||||
: '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}',
|
: '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
@@ -148,9 +208,11 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool get _canCompleteStation {
|
bool get _canCompleteStation {
|
||||||
|
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
|
||||||
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
|
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
|
||||||
// immer aktiv.
|
|
||||||
for (final t in _visibleTasks) {
|
for (final t in _visibleTasks) {
|
||||||
if (!t.optional && !_completedTasks.contains(t.id)) {
|
if (!t.optional && !_completedTasks.contains(t.id)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -175,7 +237,9 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: enabled ? _onCompleteStationPressed : null,
|
onPressed: enabled ? _onCompleteStationPressed : null,
|
||||||
icon: const Icon(Icons.flag),
|
icon: const Icon(Icons.flag),
|
||||||
label: const Text('Station abschließen'),
|
label: Text(
|
||||||
|
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
|
||||||
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
),
|
),
|
||||||
@@ -184,6 +248,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onCompleteStationPressed() {
|
void _onCompleteStationPressed() {
|
||||||
|
if (!_canCompleteStation) return;
|
||||||
if (_hasIncompleteOptionalTasks) {
|
if (_hasIncompleteOptionalTasks) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
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;
|
final stationOrder = widget.stationOrder ?? 0;
|
||||||
try {
|
try {
|
||||||
StompService().sendStationCompleted(
|
await StompService().sendStationCompleted(
|
||||||
jobId: widget.job.id,
|
jobId: _job.id,
|
||||||
jobNumber: widget.job.jobNumber,
|
jobNumber: _job.jobNumber,
|
||||||
stationOrder: stationOrder,
|
stationOrder: stationOrder,
|
||||||
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_stationCompleted = true;
|
||||||
const SnackBar(content: Text('Station abgeschlossen')),
|
_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();
|
Navigator.of(context).pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('Error sending station completion: $e', name: 'TaskView');
|
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 task = _visibleTasks[index];
|
||||||
final isCompleted = _completedTasks.contains(task.id);
|
final isCompleted = _completedTasks.contains(task.id);
|
||||||
final canBeCompletedNow =
|
final canBeCompletedNow =
|
||||||
!isCompleted && _arePreviousTasksCompleted(index);
|
_canCompleteTask(task) && _arePreviousTasksCompleted(index);
|
||||||
|
|
||||||
// Hintergrundfarbe je nach Status:
|
// Hintergrundfarbe je nach Status:
|
||||||
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
|
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
|
||||||
@@ -372,18 +447,18 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
vertical: 4,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.warningSoft,
|
color: AppColors.warningSoft,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.warning.withValues(alpha: 0.5),
|
color: AppColors.warning.withValues(alpha: 0.5),
|
||||||
width: 1,
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Optional',
|
'Optional',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -405,6 +480,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showTaskCompletionDialog(Task task, int taskIndex) {
|
void _showTaskCompletionDialog(Task task, int taskIndex) {
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
switch (task) {
|
switch (task) {
|
||||||
case ConfirmationTask():
|
case ConfirmationTask():
|
||||||
_showConfirmationDialog(task, taskIndex);
|
_showConfirmationDialog(task, taskIndex);
|
||||||
@@ -518,10 +594,12 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
(context) => PhotoCaptureScreen(
|
(context) => PhotoCaptureScreen(
|
||||||
task: task,
|
task: task,
|
||||||
onPhotosCompleted: (List<Uint8List> photoData) async {
|
onPhotosCompleted: (List<Uint8List> photoData) async {
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
// Compress + encode photos for network send (limit payload)
|
// Compress + encode photos for network send (limit payload)
|
||||||
final List<String> base64List = await _compressAndEncodePhotos(
|
final List<String> base64List = await _compressAndEncodePhotos(
|
||||||
photoData,
|
photoData,
|
||||||
);
|
);
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
final bool truncated = base64List.length < photoData.length;
|
final bool truncated = base64List.length < photoData.length;
|
||||||
|
|
||||||
// Try to persist full-quality (encoded) photos to DB for offline/backup
|
// Try to persist full-quality (encoded) photos to DB for offline/backup
|
||||||
@@ -623,6 +701,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
(context) => SignatureCaptureScreen(
|
(context) => SignatureCaptureScreen(
|
||||||
task: task,
|
task: task,
|
||||||
onSignatureCompleted: (String svg, String note) async {
|
onSignatureCompleted: (String svg, String note) async {
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
try {
|
try {
|
||||||
// Persist SVG only (no PNG)
|
// Persist SVG only (no PNG)
|
||||||
await _databaseService.saveTaskSignature(task.id, svg);
|
await _databaseService.saveTaskSignature(task.id, svg);
|
||||||
@@ -634,6 +713,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
);
|
);
|
||||||
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
developer.log('Stack trace: $stackTrace', name: 'TaskView');
|
||||||
}
|
}
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
// Store SVG for later use in this TaskView session
|
// Store SVG for later use in this TaskView session
|
||||||
setState(() {
|
setState(() {
|
||||||
_signatureSvgByTask[task.id] = svg;
|
_signatureSvgByTask[task.id] = svg;
|
||||||
@@ -666,6 +746,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
(context) => BarcodeCaptureScreen(
|
(context) => BarcodeCaptureScreen(
|
||||||
task: task,
|
task: task,
|
||||||
onBarcodesCompleted: (List<String> barcodes) async {
|
onBarcodesCompleted: (List<String> barcodes) async {
|
||||||
|
if (!_canCompleteTask(task)) return;
|
||||||
try {
|
try {
|
||||||
// Save barcodes to database for later use
|
// Save barcodes to database for later use
|
||||||
await _databaseService.saveTaskBarcodes(task.id, barcodes);
|
await _databaseService.saveTaskBarcodes(task.id, barcodes);
|
||||||
@@ -734,6 +815,8 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
String? taskType,
|
String? taskType,
|
||||||
Map<String, dynamic>? extraData,
|
Map<String, dynamic>? extraData,
|
||||||
}) {
|
}) {
|
||||||
|
final tasks = _visibleTasks.where((task) => task.id == taskId);
|
||||||
|
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
|
||||||
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
||||||
setState(() {
|
setState(() {
|
||||||
_completedTasks.add(taskId);
|
_completedTasks.add(taskId);
|
||||||
@@ -756,7 +839,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
|
||||||
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted && _canCompleteStation) {
|
||||||
_showLastMandatoryCompletedDialog();
|
_showLastMandatoryCompletedDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -899,7 +982,9 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
? localizeKnownText(context, task.description!)
|
? localizeKnownText(context, task.description!)
|
||||||
: null;
|
: null;
|
||||||
final String? signatureNote =
|
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()
|
? task.note!.trim()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -974,7 +1059,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getRemark() => widget.job.remark;
|
String _getRemark() => _job.remark;
|
||||||
|
|
||||||
String? _getTaskStationLabel(Task task) {
|
String? _getTaskStationLabel(Task task) {
|
||||||
if (widget.stationOrder != null) {
|
if (widget.stationOrder != null) {
|
||||||
@@ -985,7 +1070,7 @@ class _TaskViewState extends State<TaskView> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final station in widget.job.deliveryStations) {
|
for (final station in _job.deliveryStations) {
|
||||||
if (station.stationOrder == stationOrder) {
|
if (station.stationOrder == stationOrder) {
|
||||||
final suffix =
|
final suffix =
|
||||||
station.displayName.isNotEmpty ? station.displayName : station.city;
|
station.displayName.isNotEmpty ? station.displayName : station.city;
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.9.22+922
|
version: 0.9.25+925
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
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 "Verwende Release-Version ${VERSION}."
|
||||||
echo "Baue Production-JAR für Version ${VERSION} ..."
|
echo "Baue Production-JAR für Version ${VERSION} ..."
|
||||||
(cd "${BACKEND_DIR}" && ./mvnw -Pproduction -DskipTests -Drevision="${VERSION}" package)
|
# Klassen aus vorherigen Maven-/IDE-Builds vor dem Vaadin-Klassenscan entfernen.
|
||||||
|
(cd "${BACKEND_DIR}" && ./mvnw -e -Pproduction -DskipTests -Drevision="${VERSION}" clean package)
|
||||||
|
|
||||||
JAR_FILE_REL="target/votianlt-${VERSION}.jar"
|
JAR_FILE_REL="target/votianlt-${VERSION}.jar"
|
||||||
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
|
JAR_FILE_ABS="${BACKEND_DIR}/${JAR_FILE_REL}"
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.9.22</revision>
|
<revision>0.9.26</revision>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -7,9 +7,40 @@ window.profileInvoiceState = window.profileInvoiceState || {
|
|||||||
initialized: false
|
initialized: false
|
||||||
};
|
};
|
||||||
|
|
||||||
window.initProfileInvoiceGenerator = function() {
|
// A tab can still be hidden when Vaadin sends its selected-change response.
|
||||||
|
// Wait for its actual layout instead of relying on fixed delays and a server callback.
|
||||||
|
window.showProfileInvoiceTemplate = function(container, view, templateData, masterdata, vatRate) {
|
||||||
|
if (window.pendingProfileInvoiceLoad) {
|
||||||
|
window.pendingProfileInvoiceLoad.disconnect();
|
||||||
|
window.pendingProfileInvoiceLoad = null;
|
||||||
|
}
|
||||||
|
function loadWhenVisible() {
|
||||||
|
var rect = container.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return false;
|
||||||
|
window.invoiceGeneratorViewProfile = view;
|
||||||
|
window.profileInvoiceServiceData = null;
|
||||||
|
window.profileInvoiceVatRate = vatRate;
|
||||||
|
window.masterdataValues = masterdata;
|
||||||
|
window.initProfileInvoiceGenerator(container);
|
||||||
|
window.loadProfileTemplate(templateData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!loadWhenVisible()) {
|
||||||
|
var observer = new ResizeObserver(function() {
|
||||||
|
if (window.pendingProfileInvoiceLoad !== observer) return;
|
||||||
|
if (loadWhenVisible()) {
|
||||||
|
observer.disconnect();
|
||||||
|
if (window.pendingProfileInvoiceLoad === observer) window.pendingProfileInvoiceLoad = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.pendingProfileInvoiceLoad = observer;
|
||||||
|
observer.observe(container);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.initProfileInvoiceGenerator = function(container) {
|
||||||
var containerId = 'invoice-canvas-container-profile';
|
var containerId = 'invoice-canvas-container-profile';
|
||||||
var container = document.getElementById(containerId);
|
container = container || document.getElementById(containerId);
|
||||||
|
|
||||||
if (!container) {
|
if (!container) {
|
||||||
console.error('Canvas container not found:', containerId);
|
console.error('Canvas container not found:', containerId);
|
||||||
@@ -233,28 +264,9 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
var img = new Image();
|
var img = new Image();
|
||||||
img.onload = function() {
|
img.onload = function() {
|
||||||
imageCache[el.imageData] = img;
|
imageCache[el.imageData] = img;
|
||||||
// Calculate dimensions to maintain aspect ratio
|
// Redraw in element order: a letterhead loaded asynchronously must
|
||||||
var imgAspect = img.width / img.height;
|
// stay behind the invoice text, including after resize or removal.
|
||||||
var boxAspect = w / h;
|
draw();
|
||||||
var drawW, drawH, drawX, drawY;
|
|
||||||
|
|
||||||
if (imgAspect > boxAspect) {
|
|
||||||
drawW = w;
|
|
||||||
drawH = w / imgAspect;
|
|
||||||
drawX = x;
|
|
||||||
drawY = y + (h - drawH) / 2;
|
|
||||||
} else {
|
|
||||||
drawW = h * imgAspect;
|
|
||||||
drawH = h;
|
|
||||||
drawX = x + (w - drawW) / 2;
|
|
||||||
drawY = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
|
||||||
// Redraw selection if this element is selected
|
|
||||||
if (selectedElement && selectedElement.id === el.id) {
|
|
||||||
drawSelection(el);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
img.src = el.imageData;
|
img.src = el.imageData;
|
||||||
}
|
}
|
||||||
@@ -1322,7 +1334,7 @@ window.initProfileInvoiceGenerator = function() {
|
|||||||
} else if (el.variable.startsWith('masterdata.')) {
|
} else if (el.variable.startsWith('masterdata.')) {
|
||||||
// Masterdata variables - use actual value from masterdata or placeholder
|
// Masterdata variables - use actual value from masterdata or placeholder
|
||||||
var value = masterdata[el.variable];
|
var value = masterdata[el.variable];
|
||||||
if (value && value.trim() !== '') {
|
if (typeof value === 'string') {
|
||||||
el.text = value;
|
el.text = value;
|
||||||
} else {
|
} else {
|
||||||
el.text = '[' + el.variable.replace('masterdata.', '') + ']';
|
el.text = '[' + el.variable.replace('masterdata.', '') + ']';
|
||||||
|
|||||||
@@ -163,13 +163,13 @@ public class AiStatisticsService {
|
|||||||
- Untersuche, erwähne oder vermute niemals Daten anderer Benutzer
|
- Untersuche, erwähne oder vermute niemals Daten anderer Benutzer
|
||||||
|
|
||||||
WICHTIG - ANTWORTSTIL:
|
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 ausschließlich ein JSON-Objekt im geforderten Format aus
|
||||||
- Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus
|
- Gib niemals Gedankengänge, Zwischenschritte oder Tags wie <think> aus
|
||||||
- Wiederhole, paraphrasiere oder fasse die Benutzerfrage nicht zusammen
|
- 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"
|
- 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
|
- 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
|
- Keine Einleitung, keine Meta-Kommentare, keine Wiederholung der Anfrage
|
||||||
- Maximal 1-2 Sätze
|
- Maximal 1-2 Sätze
|
||||||
- Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen
|
- Jede genannte Zahl muss exakt aus dem bereitgestellten Kontext stammen
|
||||||
@@ -803,27 +803,27 @@ public class AiStatisticsService {
|
|||||||
List<Job> jobs;
|
List<Job> jobs;
|
||||||
if (customerFilter != null && statusFilter != null) {
|
if (customerFilter != null && statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customerFilter, statusFilter);
|
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()));
|
customerFilter, statusFilter.getDisplayName()));
|
||||||
} else if (customerFilter != null) {
|
} else if (customerFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customerFilter);
|
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) {
|
} else if (statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
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()));
|
statusFilter.getDisplayName()));
|
||||||
} else {
|
} else {
|
||||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
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)
|
// Show job summaries (max 10)
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
for (Job job : jobs) {
|
for (Job job : jobs) {
|
||||||
if (shown >= 10) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
context.append(
|
context.append(
|
||||||
@@ -943,7 +943,7 @@ public class AiStatisticsService {
|
|||||||
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
job.getCustomerSelection() != null ? job.getCustomerSelection() : "Unbekannt",
|
||||||
job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt"))
|
job.getStatus() != null ? job.getStatus().getDisplayName() : "Unbekannt"))
|
||||||
.toList();
|
.toList();
|
||||||
return buildHtmlTable(List.of("Jobnummer", "Kunde", "Status"), rows);
|
return buildHtmlTable(List.of("Auftragsnummer", "Kunde", "Status"), rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildStatusTableHtml(String currentUserId, String customerFilter) {
|
private String buildStatusTableHtml(String currentUserId, String customerFilter) {
|
||||||
@@ -1209,17 +1209,17 @@ public class AiStatisticsService {
|
|||||||
List<Job> jobs;
|
List<Job> jobs;
|
||||||
if (customer != null && statusFilter != null) {
|
if (customer != null && statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerAndStatusForUser(currentUserId, customer, statusFilter);
|
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());
|
statusFilter.getDisplayName());
|
||||||
} else if (customer != null) {
|
} else if (customer != null) {
|
||||||
jobs = statisticsService.getJobsByCustomerForUser(currentUserId, customer);
|
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) {
|
} else if (statusFilter != null) {
|
||||||
jobs = statisticsService.getJobsByStatusForUser(currentUserId, statusFilter);
|
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 {
|
} else {
|
||||||
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
jobs = statisticsService.getLatestJobsForUser(currentUserId, 20);
|
||||||
yield String.format("%d aktuelle Jobs.", jobs.size());
|
yield String.format("%d aktuelle Aufträge.", jobs.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "status" -> {
|
case "status" -> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import de.assecutor.votianlt.dto.ChatMessageInboundPayload;
|
|||||||
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
import de.assecutor.votianlt.dto.JobWithRelatedDataDTO;
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
import de.assecutor.votianlt.model.CargoItem;
|
import de.assecutor.votianlt.model.CargoItem;
|
||||||
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.task.BaseTask;
|
import de.assecutor.votianlt.model.task.BaseTask;
|
||||||
import de.assecutor.votianlt.model.task.SignatureTask;
|
import de.assecutor.votianlt.model.task.SignatureTask;
|
||||||
@@ -31,10 +32,16 @@ import de.assecutor.votianlt.model.JobStatus;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
import de.assecutor.votianlt.messaging.MessagingPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
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.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
|
|
||||||
@@ -53,6 +60,7 @@ public class MessageController {
|
|||||||
private final AppUserService appUserService;
|
private final AppUserService appUserService;
|
||||||
|
|
||||||
private final JobRepository jobRepository;
|
private final JobRepository jobRepository;
|
||||||
|
private final MongoTemplate mongoTemplate;
|
||||||
|
|
||||||
private final CargoItemRepository cargoItemRepository;
|
private final CargoItemRepository cargoItemRepository;
|
||||||
|
|
||||||
@@ -72,11 +80,12 @@ public class MessageController {
|
|||||||
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
TaskRepository taskRepository, PhotoRepository photoRepository, BarcodeRepository barcodeRepository,
|
||||||
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
SignatureRepository signatureRepository, CommentRepository commentRepository,
|
||||||
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
JobHistoryService jobHistoryService, JobUpdateBroadcaster jobUpdateBroadcaster, EmailService emailService,
|
||||||
MessageService messageService, TaskAssignmentService taskAssignmentService) {
|
MessageService messageService, TaskAssignmentService taskAssignmentService, MongoTemplate mongoTemplate) {
|
||||||
this.messagingPublisher = messagingPublisher;
|
this.messagingPublisher = messagingPublisher;
|
||||||
this.appUserRepository = appUserRepository;
|
this.appUserRepository = appUserRepository;
|
||||||
this.appUserService = appUserService;
|
this.appUserService = appUserService;
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
|
this.mongoTemplate = mongoTemplate;
|
||||||
this.cargoItemRepository = cargoItemRepository;
|
this.cargoItemRepository = cargoItemRepository;
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.photoRepository = photoRepository;
|
this.photoRepository = photoRepository;
|
||||||
@@ -178,30 +187,45 @@ public class MessageController {
|
|||||||
* go to generic processing.
|
* go to generic processing.
|
||||||
*/
|
*/
|
||||||
public void handleTaskCompleted(Map<String, Object> payload, String taskType) {
|
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();
|
String key = taskType == null ? "" : taskType.trim().toUpperCase();
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "PHOTO" -> processPhotoTaskCompletion(payload);
|
case "PHOTO" -> processPhotoTaskCompletion(payload, appUserId);
|
||||||
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload);
|
case "CONFIRMATION" -> processConfirmationTaskCompletion(payload, appUserId);
|
||||||
case "SIGNATURE" -> processSignatureTaskCompletion(payload);
|
case "SIGNATURE" -> processSignatureTaskCompletion(payload, appUserId);
|
||||||
case "TODOLIST" -> processTodoListTaskCompletion(payload);
|
case "TODOLIST" -> processTodoListTaskCompletion(payload, appUserId);
|
||||||
case "BARCODE" -> processBarcodeTaskCompletion(payload);
|
case "BARCODE" -> processBarcodeTaskCompletion(payload, appUserId);
|
||||||
case "COMMENT" -> processCommentTaskCompletion(payload);
|
case "COMMENT" -> processCommentTaskCompletion(payload, appUserId);
|
||||||
default -> log.error("[TASK] Unknown taskType: {}", taskType);
|
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");
|
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");
|
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");
|
Object taskId = payload.get("taskId");
|
||||||
try {
|
try {
|
||||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||||
@@ -220,7 +244,7 @@ public class MessageController {
|
|||||||
|
|
||||||
if (!barcodes.isEmpty()) {
|
if (!barcodes.isEmpty()) {
|
||||||
for (String barcodeString : barcodes) {
|
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,
|
Barcode barcodeEntry = new Barcode(new ObjectId(taskId.toString()), barcodeString,
|
||||||
completedBy);
|
completedBy);
|
||||||
barcodeRepository.save(barcodeEntry);
|
barcodeRepository.save(barcodeEntry);
|
||||||
@@ -238,13 +262,13 @@ public class MessageController {
|
|||||||
extraDataSummary = "Keine Extra-Daten";
|
extraDataSummary = "Keine Extra-Daten";
|
||||||
}
|
}
|
||||||
|
|
||||||
completeTaskWithHistory(taskId, extraDataSummary);
|
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("[TASK] Barcode completion error: {}", ex.getMessage());
|
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");
|
Object taskId = payload.get("taskId");
|
||||||
try {
|
try {
|
||||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||||
@@ -261,7 +285,7 @@ public class MessageController {
|
|||||||
String signatureNote = signatureNoteObj instanceof String s ? s : null;
|
String signatureNote = signatureNoteObj instanceof String s ? s : null;
|
||||||
if (signatureSvgObj instanceof String signatureSvg) {
|
if (signatureSvgObj instanceof String signatureSvg) {
|
||||||
if (!signatureSvg.isBlank()) {
|
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,
|
Signature signatureEntry = new Signature(new ObjectId(taskId.toString()), signatureSvg,
|
||||||
signatureNote, completedBy);
|
signatureNote, completedBy);
|
||||||
signatureRepository.save(signatureEntry);
|
signatureRepository.save(signatureEntry);
|
||||||
@@ -279,13 +303,13 @@ public class MessageController {
|
|||||||
extraDataSummary = "Keine Extra-Daten";
|
extraDataSummary = "Keine Extra-Daten";
|
||||||
}
|
}
|
||||||
|
|
||||||
completeTaskWithHistory(taskId, extraDataSummary);
|
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("[TASK] Signature completion error: {}", ex.getMessage());
|
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");
|
Object taskId = payload.get("taskId");
|
||||||
try {
|
try {
|
||||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||||
@@ -304,7 +328,7 @@ public class MessageController {
|
|||||||
|
|
||||||
if (!photos.isEmpty()) {
|
if (!photos.isEmpty()) {
|
||||||
for (String photoString : photos) {
|
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);
|
Photo photoEntry = new Photo(new ObjectId(taskId.toString()), photoString, completedBy);
|
||||||
photoRepository.save(photoEntry);
|
photoRepository.save(photoEntry);
|
||||||
}
|
}
|
||||||
@@ -319,13 +343,13 @@ public class MessageController {
|
|||||||
extraDataSummary = "Keine Extra-Daten";
|
extraDataSummary = "Keine Extra-Daten";
|
||||||
}
|
}
|
||||||
|
|
||||||
completeTaskWithHistory(taskId, extraDataSummary);
|
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("[TASK] Photo completion error: {}", ex.getMessage());
|
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");
|
Object taskId = payload.get("taskId");
|
||||||
try {
|
try {
|
||||||
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
var opt = taskRepository.findById(new ObjectId(taskId.toString()));
|
||||||
@@ -339,7 +363,7 @@ public class MessageController {
|
|||||||
if (extra instanceof Map<?, ?> extraData) {
|
if (extra instanceof Map<?, ?> extraData) {
|
||||||
Object commentTextObj = extraData.get("commentText");
|
Object commentTextObj = extraData.get("commentText");
|
||||||
if (commentTextObj instanceof String commentText && !commentText.isBlank()) {
|
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);
|
Comment commentEntry = new Comment(new ObjectId(taskId.toString()), commentText, completedBy);
|
||||||
commentRepository.save(commentEntry);
|
commentRepository.save(commentEntry);
|
||||||
extraDataSummary = "Kommentar: " + commentText;
|
extraDataSummary = "Kommentar: " + commentText;
|
||||||
@@ -350,13 +374,13 @@ public class MessageController {
|
|||||||
extraDataSummary = "Kommentar abgegeben";
|
extraDataSummary = "Kommentar abgegeben";
|
||||||
}
|
}
|
||||||
|
|
||||||
completeTaskWithHistory(taskId, extraDataSummary);
|
completeTaskWithHistory(taskId, extraDataSummary, appUserId);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("[TASK] Comment completion error: {}", ex.getMessage());
|
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();
|
String taskIdStr = tid.toString();
|
||||||
try {
|
try {
|
||||||
ObjectId taskId = new ObjectId(taskIdStr);
|
ObjectId taskId = new ObjectId(taskIdStr);
|
||||||
@@ -365,23 +389,23 @@ public class MessageController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BaseTask task = opt.get();
|
BaseTask task = opt.get();
|
||||||
|
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
|
||||||
|
if (jobOpt.isEmpty() || !canCompleteTask(task, jobOpt.get())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.setCompletedBy(resolveTaskActor(task, appUserId));
|
||||||
task.setCompleted(true);
|
task.setCompleted(true);
|
||||||
task.setCompletedAt(LocalDateTime.now());
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
taskRepository.save(task);
|
taskRepository.save(task);
|
||||||
Optional<Job> jobOpt = taskAssignmentService.findJobForTask(task);
|
|
||||||
if (jobOpt.isEmpty()) {
|
|
||||||
log.warn("[TASK] Could not resolve job for task {}", taskIdStr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ObjectId jobId = jobOpt.get().getId();
|
ObjectId jobId = jobOpt.get().getId();
|
||||||
|
|
||||||
// Log detailed task completion in job history
|
// Log detailed task completion in job history
|
||||||
try {
|
try {
|
||||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||||
String taskDisplayName = task.getDisplayName() != null ? task.getDisplayName() : taskType;
|
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,
|
jobHistoryService.logTaskCompletion(jobId, taskType, taskIdStr, completedBy, taskDisplayName,
|
||||||
extraDataSummary);
|
extraDataSummary, task.getDescription() != null ? task.getDescription() : "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
|
log.error("[TASK] Could not write job history for task {} of job {}: {}", taskIdStr, jobId,
|
||||||
e.getMessage(), e);
|
e.getMessage(), e);
|
||||||
@@ -390,8 +414,9 @@ public class MessageController {
|
|||||||
// Send email notification for task completion
|
// Send email notification for task completion
|
||||||
try {
|
try {
|
||||||
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown";
|
||||||
String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
String completedBy = resolveTaskActor(task, appUserId);
|
||||||
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy);
|
emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy,
|
||||||
|
task.getDescription());
|
||||||
// Job completion is no longer auto-triggered by task completion.
|
// Job completion is no longer auto-triggered by task completion.
|
||||||
// It is now driven by explicit station_completed messages from the app
|
// It is now driven by explicit station_completed messages from the app
|
||||||
// (see handleStationCompleted).
|
// (see handleStationCompleted).
|
||||||
@@ -406,55 +431,39 @@ public class MessageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkAndHandleJobCompletion(ObjectId jobId, String completedBy) {
|
private boolean canCompleteTask(BaseTask task, Job job) {
|
||||||
try {
|
if (task.isCompleted() || job.getStatus() == JobStatus.COMPLETED || job.getStatus() == JobStatus.CANCELLED) {
|
||||||
var allTasks = taskAssignmentService.findTasksForJob(jobId);
|
return false;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
private String resolveTaskActor(BaseTask task, String appUserId) {
|
||||||
try {
|
if (appUserId != null && !appUserId.isBlank()) {
|
||||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
return appUserId;
|
||||||
if (jobOpt.isEmpty()) {
|
}
|
||||||
log.warn("[JOB] Cannot mark job {} as completed: job not found", jobId);
|
return task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown";
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Job job = jobOpt.get();
|
private boolean updateJobStatusToCompleted(ObjectId jobId) {
|
||||||
if (job.getStatus() != JobStatus.COMPLETED) {
|
try {
|
||||||
job.setStatus(JobStatus.COMPLETED);
|
Query query = Query.query(Criteria.where("_id").is(jobId)
|
||||||
job.setUpdatedAt(LocalDateTime.now());
|
.and("status").nin(JobStatus.COMPLETED, JobStatus.CANCELLED));
|
||||||
jobRepository.save(job);
|
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);
|
log.info("[JOB] Job {} marked as COMPLETED", jobId);
|
||||||
}
|
}
|
||||||
|
return completed;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[JOB] Could not update status of job {} to COMPLETED: {}", jobId, e.getMessage(), 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
|
* "hasIncompleteOptionalTasks": false
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* The job is marked as completed once this message is received and all
|
* Intermediate stations send a station notification. The final route station
|
||||||
* mandatory tasks across all stations are completed.
|
* 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) {
|
public void handleStationCompleted(String appUserId, Map<String, Object> payload) {
|
||||||
try {
|
try {
|
||||||
@@ -490,19 +500,76 @@ public class MessageController {
|
|||||||
return;
|
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";
|
String completedBy = appUserId != null ? appUserId : "Unknown";
|
||||||
|
|
||||||
log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber,
|
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);
|
jobUpdateBroadcaster.broadcast(jobId);
|
||||||
} catch (Exception e) {
|
} 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
|
* Handle incoming message from a client via WebSocket. Client sends to
|
||||||
* /server/message with payload: { "content": "message payload", "contentType":
|
* /server/message with payload: { "content": "message payload", "contentType":
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ public class MessagingConfig {
|
|||||||
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
|
webSocketService.registerMessageHandler("task_completed", (appUserId, payload) -> {
|
||||||
handlePayload(payload, payloadMap -> {
|
handlePayload(payload, payloadMap -> {
|
||||||
String taskType = payloadMap.get("taskType") != null ? payloadMap.get("taskType").toString() : null;
|
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
|
// Station completion handler — notifies for intermediate stations and completes
|
||||||
// tasks have been finished and the app confirms the station is done.
|
// the job after the final station and all mandatory tasks are done.
|
||||||
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
|
webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> {
|
||||||
handlePayload(payload, payloadMap -> {
|
handlePayload(payload, payloadMap -> {
|
||||||
messageController.handleStationCompleted(appUserId, payloadMap);
|
messageController.handleStationCompleted(appUserId, payloadMap);
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ public class Job {
|
|||||||
@Field("delivery_stations")
|
@Field("delivery_stations")
|
||||||
private List<DeliveryStation> deliveryStations = new ArrayList<>();
|
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
|
* 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.
|
* job id is returned as a string when jobs are retrieved via API.
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ public class JobHistory {
|
|||||||
*/
|
*/
|
||||||
private String details;
|
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.)
|
* Type of change (CREATE, UPDATE, STATUS_CHANGE, DELETE, etc.)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
@@ -1600,6 +1600,7 @@ public class DeliveryStationDialog extends Dialog {
|
|||||||
|
|
||||||
if (copy != null) {
|
if (copy != null) {
|
||||||
copy.setDescription(original.getDescription());
|
copy.setDescription(original.getDescription());
|
||||||
|
copy.setOptional(original.isOptional());
|
||||||
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
copy.setTaskOrder(original.getTaskOrder() != null ? original.getTaskOrder() : 0);
|
||||||
copy.setCompleted(original.isCompleted());
|
copy.setCompleted(original.isCompleted());
|
||||||
copy.setCompletedAt(original.getCompletedAt());
|
copy.setCompletedAt(original.getCompletedAt());
|
||||||
|
|||||||
+1
-1
@@ -310,7 +310,7 @@ public class PickupStationDialog extends Dialog {
|
|||||||
|
|
||||||
customerLabelMap.clear();
|
customerLabelMap.clear();
|
||||||
for (Customer c : customers) {
|
for (Customer c : customers) {
|
||||||
CustomerAddressLabelHelper.putName(customerLabelMap, c,
|
CustomerAddressLabelHelper.put(customerLabelMap, c,
|
||||||
translationHelper.getTranslation("addjob.customer.unnamed"));
|
translationHelper.getTranslation("addjob.customer.unnamed"));
|
||||||
}
|
}
|
||||||
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
customerComboBox.setItems(new ArrayList<>(customerLabelMap.keySet()));
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ public class AddJobService {
|
|||||||
* Generiert eine eindeutige Auftragsnummer
|
* Generiert eine eindeutige Auftragsnummer
|
||||||
*/
|
*/
|
||||||
private String generateJobNumber() {
|
private String generateJobNumber() {
|
||||||
String prefix = "JOB";
|
String prefix = "AUFTRAG";
|
||||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
|
||||||
|
|
||||||
// Zähle Aufträge des aktuellen Tages
|
// 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 = new TextField(getTranslation("profile.company"));
|
||||||
companyName.setWidthFull();
|
companyName.setWidthFull();
|
||||||
|
|
||||||
// Steuernummer (Pflichtfeld)
|
// Steuernummer (optional)
|
||||||
taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
|
taxNumber = new TextField(getTranslation("profile.billing.taxnumber"));
|
||||||
taxNumber.setRequiredIndicatorVisible(true);
|
|
||||||
taxNumber.setWidthFull();
|
taxNumber.setWidthFull();
|
||||||
taxNumber.addBlurListener(e -> validateField(taxNumber));
|
|
||||||
|
|
||||||
// USt-IdNr. (Pflichtfeld)
|
// USt-IdNr. (optional)
|
||||||
ustId = new TextField(getTranslation("profile.billing.ustid"));
|
ustId = new TextField(getTranslation("profile.billing.ustid"));
|
||||||
ustId.setRequiredIndicatorVisible(true);
|
|
||||||
ustId.setPlaceholder("DE123456789");
|
ustId.setPlaceholder("DE123456789");
|
||||||
ustId.setWidthFull();
|
ustId.setWidthFull();
|
||||||
ustId.addBlurListener(e -> validateUstId());
|
ustId.addBlurListener(e -> validateUstId());
|
||||||
@@ -181,11 +178,11 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
private void configureBinder() {
|
private void configureBinder() {
|
||||||
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
binder.forField(companyName).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||||
|
|
||||||
binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required"))
|
binder.forField(taxNumber).bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||||
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
|
||||||
|
|
||||||
binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required"))
|
binder.forField(ustId)
|
||||||
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
|
.withValidator(value -> value == null || value.isBlank()
|
||||||
|
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
|
||||||
getTranslation("profile.validation.ustid.invalid"))
|
getTranslation("profile.validation.ustid.invalid"))
|
||||||
.bind(Customer::getUstId, Customer::setUstId);
|
.bind(Customer::getUstId, Customer::setUstId);
|
||||||
|
|
||||||
@@ -265,15 +262,12 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format
|
* Prüft eine angegebene USt-IdNr. auf gültiges Format
|
||||||
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
|
* (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789).
|
||||||
*/
|
*/
|
||||||
private void validateUstId() {
|
private void validateUstId() {
|
||||||
String value = ustId.getValue();
|
String value = ustId.getValue();
|
||||||
if (value == null || value.trim().isEmpty()) {
|
if (value != null && !value.isBlank() && !de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
|
||||||
ustId.setInvalid(true);
|
|
||||||
ustId.setErrorMessage(getTranslation("profile.validation.ustid.required"));
|
|
||||||
} else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) {
|
|
||||||
ustId.setInvalid(true);
|
ustId.setInvalid(true);
|
||||||
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
|
ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid"));
|
||||||
} else {
|
} else {
|
||||||
@@ -305,7 +299,6 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean validateAllFields() {
|
private boolean validateAllFields() {
|
||||||
validateField(taxNumber);
|
|
||||||
validateUstId();
|
validateUstId();
|
||||||
validateField(firstName);
|
validateField(firstName);
|
||||||
validateField(lastName);
|
validateField(lastName);
|
||||||
@@ -316,7 +309,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle {
|
|||||||
validateField(city);
|
validateField(city);
|
||||||
validateEmail();
|
validateEmail();
|
||||||
|
|
||||||
return !taxNumber.isInvalid() && !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
return !ustId.isInvalid() && !firstName.isInvalid() && !lastName.isInvalid()
|
||||||
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
|
&& !telephone.isInvalid() && !mail.isInvalid() && !street.isInvalid() && !houseNumber.isInvalid()
|
||||||
&& !zip.isInvalid() && !city.isInvalid();
|
&& !zip.isInvalid() && !city.isInvalid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ public class AddJobView extends Main implements HasDynamicTitle {
|
|||||||
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
List<Customer> ownerCustomers = customerService.findAllForCurrentOwner();
|
||||||
customerLabelToEntity.clear();
|
customerLabelToEntity.clear();
|
||||||
for (Customer c : ownerCustomers) {
|
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()));
|
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.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
|
||||||
import de.assecutor.votianlt.service.EmailService;
|
import de.assecutor.votianlt.service.EmailService;
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
@@ -546,15 +547,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
|
|
||||||
currentUser = currentUserOpt.get();
|
currentUser = currentUserOpt.get();
|
||||||
|
|
||||||
Optional<InvoiceTemplate> templateOpt = invoiceTemplateService
|
InvoiceTemplate template = invoiceTemplateService
|
||||||
.getTemplateByUserId(currentUser.getId().toString());
|
.getCustomerTemplateByUserId(currentUser.getId().toString());
|
||||||
if (templateOpt.isEmpty()) {
|
String templateData = template.getTemplateData();
|
||||||
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
|
|
||||||
Notification.Position.BOTTOM_END);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String templateData = templateOpt.get().getTemplateData();
|
|
||||||
if (templateData == null || templateData.isBlank()) {
|
if (templateData == null || templateData.isBlank()) {
|
||||||
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
|
Notification.show(getTranslation("createinvoice.notification.notemplate"), 3000,
|
||||||
Notification.Position.BOTTOM_END);
|
Notification.Position.BOTTOM_END);
|
||||||
@@ -822,15 +817,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
BigDecimal totalAmount = netAmount.add(vatAmount);
|
BigDecimal totalAmount = netAmount.add(vatAmount);
|
||||||
|
|
||||||
// Build variable substitution map
|
// Build variable substitution map
|
||||||
Map<String, String> variables = new HashMap<>();
|
Map<String, String> variables = CustomerInvoiceTemplateVariables.create(currentUser,
|
||||||
|
userInvoiceDataService.findByUserId(currentUser.getId()).orElse(null), invoiceNumber);
|
||||||
// Master data (from user profile)
|
|
||||||
variables.put("masterdata.company_name", safe(currentUser.getCompany()));
|
|
||||||
variables.put("masterdata.contact_name", safe(currentUser.getFirstname()) + " " + safe(currentUser.getName()));
|
|
||||||
variables.put("masterdata.street", safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber()));
|
|
||||||
variables.put("masterdata.city", safe(currentUser.getZip()) + " " + safe(currentUser.getCity()));
|
|
||||||
variables.put("masterdata.email", safe(currentUser.getEmail()));
|
|
||||||
variables.put("masterdata.phone", safe(currentUser.getPhone()));
|
|
||||||
|
|
||||||
// Customer data (from job - look up Customer entity for full address)
|
// Customer data (from job - look up Customer entity for full address)
|
||||||
String customerSelection = currentJob.getCustomerSelection();
|
String customerSelection = currentJob.getCustomerSelection();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.assecutor.votianlt.pages.view;
|
|||||||
|
|
||||||
import com.vaadin.flow.component.button.Button;
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.button.ButtonVariant;
|
import com.vaadin.flow.component.button.ButtonVariant;
|
||||||
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.formlayout.FormLayout;
|
import com.vaadin.flow.component.formlayout.FormLayout;
|
||||||
import com.vaadin.flow.component.html.H2;
|
import com.vaadin.flow.component.html.H2;
|
||||||
@@ -34,7 +35,7 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
|||||||
private final Binder<Customer> binder = new Binder<>(Customer.class);
|
private final Binder<Customer> binder = new Binder<>(Customer.class);
|
||||||
|
|
||||||
// Form fields - labels set in constructor via setLabel()
|
// Form fields - labels set in constructor via setLabel()
|
||||||
private final TextField titleField = new TextField();
|
private final ComboBox<String> titleField = new ComboBox<>();
|
||||||
private final TextField companyNameField = new TextField();
|
private final TextField companyNameField = new TextField();
|
||||||
private final TextField taxNumberField = new TextField();
|
private final TextField taxNumberField = new TextField();
|
||||||
private final TextField ustIdField = new TextField();
|
private final TextField ustIdField = new TextField();
|
||||||
@@ -62,11 +63,12 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
|||||||
|
|
||||||
// Set field labels via i18n
|
// Set field labels via i18n
|
||||||
titleField.setLabel(getTranslation("addjob.address.salutation"));
|
titleField.setLabel(getTranslation("addjob.address.salutation"));
|
||||||
|
titleField.setItems(getTranslation("addjob.salutation.mr"), getTranslation("addjob.salutation.ms"),
|
||||||
|
getTranslation("addjob.salutation.other"));
|
||||||
|
titleField.setPlaceholder(getTranslation("addjob.address.salutation.placeholder"));
|
||||||
companyNameField.setLabel(getTranslation("profile.company"));
|
companyNameField.setLabel(getTranslation("profile.company"));
|
||||||
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
|
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
|
||||||
taxNumberField.setRequiredIndicatorVisible(true);
|
|
||||||
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
|
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
|
||||||
ustIdField.setRequiredIndicatorVisible(true);
|
|
||||||
ustIdField.setPlaceholder("DE123456789");
|
ustIdField.setPlaceholder("DE123456789");
|
||||||
firstnameField.setLabel(getTranslation("profile.firstname"));
|
firstnameField.setLabel(getTranslation("profile.firstname"));
|
||||||
lastNameField.setLabel(getTranslation("profile.lastname"));
|
lastNameField.setLabel(getTranslation("profile.lastname"));
|
||||||
@@ -143,10 +145,10 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter<
|
|||||||
private void setupBinder() {
|
private void setupBinder() {
|
||||||
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
|
binder.forField(titleField).bind(Customer::getTitle, Customer::setTitle);
|
||||||
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
|
binder.forField(companyNameField).bind(Customer::getCompanyName, Customer::setCompanyName);
|
||||||
binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required"))
|
binder.forField(taxNumberField).bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
||||||
.bind(Customer::getTaxNumber, Customer::setTaxNumber);
|
binder.forField(ustIdField)
|
||||||
binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required"))
|
.withValidator(value -> value == null || value.isBlank()
|
||||||
.withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid,
|
|| de.assecutor.votianlt.util.VatIdUtil.isValid(value),
|
||||||
getTranslation("profile.validation.ustid.invalid"))
|
getTranslation("profile.validation.ustid.invalid"))
|
||||||
.bind(Customer::getUstId, Customer::setUstId);
|
.bind(Customer::getUstId, Customer::setUstId);
|
||||||
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
|
binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
|||||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
|
import de.assecutor.votianlt.service.CustomerInvoiceTemplateVariables;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
import de.assecutor.votianlt.service.LanguageService;
|
import de.assecutor.votianlt.service.LanguageService;
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
@@ -78,6 +79,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
private Checkbox billingEnabled;
|
private Checkbox billingEnabled;
|
||||||
private NumberField vatRateField;
|
private NumberField vatRateField;
|
||||||
private VerticalLayout propertiesPanelProfile;
|
private VerticalLayout propertiesPanelProfile;
|
||||||
|
private final Div canvasContainer;
|
||||||
|
|
||||||
private final ServiceRepository serviceRepository;
|
private final ServiceRepository serviceRepository;
|
||||||
private Grid<Service> servicesGrid;
|
private Grid<Service> servicesGrid;
|
||||||
@@ -414,7 +416,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
|
.set("border-radius", "var(--lumo-border-radius-m)").set("padding", "var(--lumo-space-m)");
|
||||||
|
|
||||||
// Mitte: Canvas
|
// Mitte: Canvas
|
||||||
Div canvasContainer = new Div();
|
canvasContainer = new Div();
|
||||||
canvasContainer.setId("invoice-canvas-container-profile");
|
canvasContainer.setId("invoice-canvas-container-profile");
|
||||||
canvasContainer.setWidth("100%");
|
canvasContainer.setWidth("100%");
|
||||||
canvasContainer.setHeight("100%");
|
canvasContainer.setHeight("100%");
|
||||||
@@ -513,17 +515,10 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
// Bestehende Rechnungsdaten laden (nur für die Checkbox)
|
// Bestehende Rechnungsdaten laden (nur für die Checkbox)
|
||||||
loadInvoiceData();
|
loadInvoiceData();
|
||||||
|
|
||||||
// Initialize invoice generator when the billing tab is selected
|
// Load directly; the client waits until the billing canvas is visible.
|
||||||
// Also register this view instance for JavaScript callbacks
|
|
||||||
tabSheet.addSelectedChangeListener(e -> {
|
tabSheet.addSelectedChangeListener(e -> {
|
||||||
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
if (getTranslation("profile.invoicecreation").equals(e.getSelectedTab().getLabel())) {
|
||||||
getElement().executeJs("window.invoiceGeneratorViewProfile = $0;"
|
loadInvoiceTemplate();
|
||||||
+ "window.profileInvoiceServiceData = null;" + "setTimeout(function() { "
|
|
||||||
+ " if (window.initProfileInvoiceGenerator) { " + " window.initProfileInvoiceGenerator(); "
|
|
||||||
+ " console.log('Canvas initialized, now loading template...'); "
|
|
||||||
+ " setTimeout(function() { " + " $0.$server.onCanvasReady(); " + " }, 200); "
|
|
||||||
+ " } else { " + " console.error('initProfileInvoiceGenerator not found'); " + " } "
|
|
||||||
+ "}, 300);", getElement());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -858,6 +853,16 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
|
currentInvoiceData = userInvoiceDataService.createOrUpdate(currentUser.getId(), billingEnabled.getValue(),
|
||||||
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
|
prefixField.getValue(), ustIdField.getValue(), taxNumberField.getValue(), bankNameField.getValue(),
|
||||||
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue());
|
ibanField.getValue(), "19", introTextArea.getValue(), termsTextArea.getValue());
|
||||||
|
try {
|
||||||
|
String valuesJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
|
||||||
|
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData,
|
||||||
|
prefixField.getValue() + "12345"));
|
||||||
|
getElement().executeJs("if (window.updateProfileMasterdataValue) { "
|
||||||
|
+ "Object.entries(JSON.parse($0)).forEach(([key, value]) => "
|
||||||
|
+ "window.updateProfileMasterdataValue(key, value)); }", valuesJson);
|
||||||
|
} catch (com.fasterxml.jackson.core.JsonProcessingException ex) {
|
||||||
|
throw new IllegalStateException("Could not update invoice template values", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String safe(String value) {
|
private String safe(String value) {
|
||||||
@@ -957,7 +962,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
}
|
}
|
||||||
BigDecimal previewVatRate = getPreviewVatRate();
|
BigDecimal previewVatRate = getPreviewVatRate();
|
||||||
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
|
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplate(templateData,
|
||||||
currentUser, prefixField.getValue(), previewVatRate);
|
currentUser, prefixField.getValue(), previewVatRate, currentInvoiceData);
|
||||||
showPdfInDialog(pdfBytes);
|
showPdfInDialog(pdfBytes);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
|
Notification.show(getTranslation("profile.invoice.pdf.preview.error", ex.getMessage()),
|
||||||
@@ -1479,62 +1484,23 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by JavaScript when the canvas is ready
|
|
||||||
*/
|
|
||||||
@ClientCallable
|
|
||||||
public void onCanvasReady() {
|
|
||||||
System.out.println("Canvas ready, loading template...");
|
|
||||||
loadInvoiceTemplate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the saved invoice template from database and display it on the canvas
|
* Load the saved invoice template from database and display it on the canvas
|
||||||
*/
|
*/
|
||||||
private void loadInvoiceTemplate() {
|
private void loadInvoiceTemplate() {
|
||||||
try {
|
try {
|
||||||
java.util.Optional<de.assecutor.votianlt.model.InvoiceTemplate> optionalTemplate = invoiceTemplateService
|
String templateData = invoiceTemplateService.getCustomerTemplateByUserId(currentUser.getId().toString())
|
||||||
.getTemplateByUserId(currentUser.getId().toString());
|
.getTemplateData();
|
||||||
if (optionalTemplate.isPresent()) {
|
if (templateData == null || templateData.isBlank()) {
|
||||||
String templateData = optionalTemplate.get().getTemplateData();
|
return;
|
||||||
if (templateData != null && !templateData.isEmpty()) {
|
|
||||||
System.out.println("Loading template data: "
|
|
||||||
+ templateData.substring(0, Math.min(100, templateData.length())) + "...");
|
|
||||||
// Escape the JSON string for JavaScript
|
|
||||||
String escapedJson = templateData.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r");
|
|
||||||
// Build masterdata values JSON
|
|
||||||
String company = safe(currentUser.getCompany());
|
|
||||||
String fullName = safe(currentUser.getFirstname()) + " " + safe(currentUser.getName());
|
|
||||||
String street = safe(currentUser.getStreet()) + " " + safe(currentUser.getHouseNumber());
|
|
||||||
String city = safe(currentUser.getZip()) + " " + safe(currentUser.getCity());
|
|
||||||
String email = safe(currentUser.getEmail());
|
|
||||||
String phone = safe(currentUser.getPhone());
|
|
||||||
String prefix = prefixField.getValue();
|
|
||||||
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
|
|
||||||
String masterdataJson = "{" + "'masterdata.company_name': '" + company.replace("'", "\\'") + "',"
|
|
||||||
+ "'masterdata.contact_name': '" + fullName.replace("'", "\\'") + "',"
|
|
||||||
+ "'masterdata.street': '" + street.replace("'", "\\'") + "'," + "'masterdata.city': '"
|
|
||||||
+ city.replace("'", "\\'") + "'," + "'masterdata.email': '" + email.replace("'", "\\'")
|
|
||||||
+ "'," + "'masterdata.phone': '" + phone.replace("'", "\\'") + "',"
|
|
||||||
+ "'masterdata.invoice_number': '" + invoiceNumber.replace("'", "\\'") + "'" + "}";
|
|
||||||
BigDecimal rate = currentUser.getVatRate() != null ? currentUser.getVatRate()
|
|
||||||
: Service.FIXED_VAT_RATE;
|
|
||||||
double vatRateJs = rate.doubleValue();
|
|
||||||
getElement().executeJs("setTimeout(function() { "
|
|
||||||
+ " if (window.loadProfileTemplate && document.getElementById('invoice-canvas-container-profile')) { "
|
|
||||||
+ " console.log('Loading template into canvas...'); "
|
|
||||||
+ " window.profileInvoiceServiceData = null; "
|
|
||||||
+ " window.profileInvoiceVatRate = " + vatRateJs + "; "
|
|
||||||
+ " window.masterdataValues = "
|
|
||||||
+ masterdataJson + "; " + " var templateData = JSON.parse('" + escapedJson + "'); "
|
|
||||||
+ " window.loadProfileTemplate(templateData); " + " } else { "
|
|
||||||
+ " console.error('loadProfileTemplate or canvas not available'); " + " } "
|
|
||||||
+ "}, 300);");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
System.out.println("No template found for user: " + currentUser.getId());
|
|
||||||
}
|
}
|
||||||
|
String prefix = prefixField.getValue();
|
||||||
|
String invoiceNumber = (prefix != null && !prefix.isBlank() ? prefix : "") + "12345";
|
||||||
|
String masterdataJson = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(
|
||||||
|
CustomerInvoiceTemplateVariables.create(currentUser, currentInvoiceData, invoiceNumber));
|
||||||
|
getElement().executeJs("window.showProfileInvoiceTemplate($0, $1, JSON.parse($2), JSON.parse($3), $4);",
|
||||||
|
canvasContainer.getElement(), getElement(), templateData, masterdataJson,
|
||||||
|
getPreviewVatRate().doubleValue());
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
|
System.err.println("Fehler beim Laden des Templates: " + ex.getMessage());
|
||||||
ex.printStackTrace();
|
ex.printStackTrace();
|
||||||
|
|||||||
@@ -209,6 +209,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
cardContent.add(description);
|
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
|
// Photo preview for photo tasks
|
||||||
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null
|
if (entry.getChangeType() == JobHistoryType.TASK_COMPLETED && entry.getDetails() != null
|
||||||
&& entry.getDetails().contains("Task-Typ: PHOTO")) {
|
&& entry.getDetails().contains("Task-Typ: PHOTO")) {
|
||||||
@@ -239,9 +247,10 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changed by (if available)
|
// Author of this history entry.
|
||||||
if (entry.getChangedBy() != null && !entry.getChangedBy().isBlank()) {
|
String createdBy = jobHistoryService.getCreatedByName(entry);
|
||||||
Span changedBy = new Span(getTranslation("jobhistory.changedby", entry.getChangedBy()));
|
if (createdBy != null && !createdBy.isBlank()) {
|
||||||
|
Span changedBy = new Span(getTranslation("jobhistory.changedby", createdBy));
|
||||||
changedBy.getStyle().set("color", "var(--lumo-secondary-text-color)")
|
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("font-size", "var(--lumo-font-size-xs)").set("margin-top", "var(--lumo-space-xs)")
|
||||||
.set("display", "block");
|
.set("display", "block");
|
||||||
@@ -260,7 +269,7 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||||
return getTranslation("jobhistory.entry.create.reason");
|
return getTranslation("jobhistory.entry.create.reason");
|
||||||
}
|
}
|
||||||
return entry.getReason() != null ? entry.getReason() : "";
|
return updateOrderTerminology(entry.getReason());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getLocalizedDescription(JobHistory entry) {
|
private String getLocalizedDescription(JobHistory entry) {
|
||||||
@@ -270,7 +279,14 @@ public class JobHistoryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
if (entry.getChangeType() == JobHistoryType.CREATE) {
|
||||||
return getTranslation("jobhistory.entry.create.description", extractDescriptionValue(entry.getDescription()));
|
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) {
|
private String extractDescriptionValue(String description) {
|
||||||
|
|||||||
@@ -555,6 +555,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String summarizeDeliveryTask(BaseTask task) {
|
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) {
|
if (task instanceof ConfirmationTask confirmationTask) {
|
||||||
String buttonText = trimToNull(confirmationTask.getButtonText());
|
String buttonText = trimToNull(confirmationTask.getButtonText());
|
||||||
if (buttonText != null) {
|
if (buttonText != null) {
|
||||||
@@ -1140,6 +1146,12 @@ public class JobSummaryView extends Main implements HasUrlParameter<String>, Has
|
|||||||
}
|
}
|
||||||
dialogContent.add(statusSpan);
|
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
|
// Task-specific details
|
||||||
addTaskSpecificDetails(dialogContent, task);
|
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())));
|
dialogContent.add(new Span("Abgeschlossen am: " + formatDateTime(task.getCompletedAt())));
|
||||||
}
|
}
|
||||||
if (task.getCompletedBy() != null && !task.getCompletedBy().isBlank()) {
|
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");
|
taskDescription.addClassName("job-task-description");
|
||||||
|
|
||||||
taskContent.add(headerRow, taskDescription);
|
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
|
// Status indicator
|
||||||
Div statusIndicator = new Div();
|
Div statusIndicator = new Div();
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ public class CustomerInvoiceService {
|
|||||||
|
|
||||||
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||||
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
String invoicePrefix, BigDecimal vatRate) throws Exception {
|
||||||
|
return generatePdfFromCanvasTemplate(jsonTemplateData, user, invoicePrefix, vatRate, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generatePdfFromCanvasTemplate(String jsonTemplateData, de.assecutor.votianlt.model.User user,
|
||||||
|
String invoicePrefix, BigDecimal vatRate, de.assecutor.votianlt.model.UserInvoiceData billing)
|
||||||
|
throws Exception {
|
||||||
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
BigDecimal effectiveVatRate = vatRate != null ? vatRate
|
||||||
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
: (user != null && user.getVatRate() != null ? user.getVatRate() : new BigDecimal("0.19"));
|
||||||
|
|
||||||
@@ -270,16 +276,8 @@ public class CustomerInvoiceService {
|
|||||||
java.util.Map<String, String> variables = new java.util.HashMap<>();
|
java.util.Map<String, String> variables = new java.util.HashMap<>();
|
||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// Use actual user data
|
variables.putAll(CustomerInvoiceTemplateVariables.create(user, billing,
|
||||||
String company = user.getCompany();
|
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345"));
|
||||||
variables.put("masterdata.company_name", (company != null && !company.isEmpty()) ? company : "Ihre Firma");
|
|
||||||
variables.put("masterdata.contact_name", safe(user.getFirstname()) + " " + safe(user.getName()));
|
|
||||||
variables.put("masterdata.street", safe(user.getStreet()) + " " + safe(user.getHouseNumber()));
|
|
||||||
variables.put("masterdata.city", safe(user.getZip()) + " " + safe(user.getCity()));
|
|
||||||
variables.put("masterdata.email", safe(user.getEmail()));
|
|
||||||
variables.put("masterdata.phone", safe(user.getPhone()));
|
|
||||||
variables.put("masterdata.invoice_number",
|
|
||||||
(invoicePrefix != null && !invoicePrefix.isBlank() ? invoicePrefix : "") + "12345");
|
|
||||||
} else {
|
} else {
|
||||||
// Default values for preview without user
|
// Default values for preview without user
|
||||||
variables.put("masterdata.company_name", "Meine Firma GmbH");
|
variables.put("masterdata.company_name", "Meine Firma GmbH");
|
||||||
@@ -324,7 +322,7 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("<style>");
|
htmlBuilder.append("<style>");
|
||||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||||
htmlBuilder.append(
|
htmlBuilder.append(
|
||||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
|
||||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||||
@@ -403,6 +401,9 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
||||||
.append("mm;");
|
.append("mm;");
|
||||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||||
|
String fontStyle = element.path("fontStyle").asText("");
|
||||||
|
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
|
||||||
|
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
|
||||||
htmlBuilder.append("line-height:")
|
htmlBuilder.append("line-height:")
|
||||||
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
||||||
htmlBuilder.append("color:").append(color).append(";");
|
htmlBuilder.append("color:").append(color).append(";");
|
||||||
@@ -572,6 +573,14 @@ public class CustomerInvoiceService {
|
|||||||
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
|
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal)).append("</td>");
|
||||||
html.append("</tr>");
|
html.append("</tr>");
|
||||||
|
|
||||||
|
html.append("<tr>");
|
||||||
|
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||||
|
html.append("<td style='width:20%;text-align:left;padding:2px 8px;white-space:nowrap;'>zzgl. ")
|
||||||
|
.append(vatLabel).append(" USt:</td>");
|
||||||
|
html.append("<td style='width:25%;text-align:right;padding:2px 8px;white-space:nowrap;font-weight:bold;'>")
|
||||||
|
.append(String.format(java.util.Locale.GERMANY, "%,.2f €", netTotal * vatRate.doubleValue()))
|
||||||
|
.append("</td></tr>");
|
||||||
|
|
||||||
// Gesamtsumme - label in col 2, value in col 3
|
// Gesamtsumme - label in col 2, value in col 3
|
||||||
html.append("<tr>");
|
html.append("<tr>");
|
||||||
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
html.append("<td style='width:55%;padding:2px 0;'></td>");
|
||||||
@@ -626,7 +635,7 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("<style>");
|
htmlBuilder.append("<style>");
|
||||||
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
htmlBuilder.append("@page { size: A4; margin: 0; }");
|
||||||
htmlBuilder.append(
|
htmlBuilder.append(
|
||||||
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Arial, sans-serif; }");
|
"body { margin: 0; padding: 0; width: 210mm; height: 297mm; position: relative; font-family: Helvetica, Arial, sans-serif; }");
|
||||||
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
htmlBuilder.append(".element { position: absolute; box-sizing: border-box; overflow: hidden; }");
|
||||||
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
htmlBuilder.append(".text { white-space: nowrap; overflow: visible; }");
|
||||||
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
htmlBuilder.append(".line { border-top: 1px solid #333; }");
|
||||||
@@ -718,6 +727,9 @@ public class CustomerInvoiceService {
|
|||||||
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
htmlBuilder.append("height:").append(String.format(java.util.Locale.US, "%.2f", mmHeight))
|
||||||
.append("mm;");
|
.append("mm;");
|
||||||
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
htmlBuilder.append("font-size:").append(fontSize).append("pt;");
|
||||||
|
String fontStyle = element.path("fontStyle").asText("");
|
||||||
|
htmlBuilder.append("font-weight:").append(fontStyle.contains("bold") ? "bold;" : "normal;");
|
||||||
|
htmlBuilder.append("font-style:").append(fontStyle.contains("italic") ? "italic;" : "normal;");
|
||||||
htmlBuilder.append("line-height:")
|
htmlBuilder.append("line-height:")
|
||||||
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
.append(String.format(java.util.Locale.US, "%.2f", fontSize * 1.2)).append("pt;");
|
||||||
htmlBuilder.append("color:").append(color).append(";");
|
htmlBuilder.append("color:").append(color).append(";");
|
||||||
|
|||||||
+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;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
import de.assecutor.votianlt.model.Barcode;
|
import de.assecutor.votianlt.model.Barcode;
|
||||||
import de.assecutor.votianlt.model.Comment;
|
import de.assecutor.votianlt.model.Comment;
|
||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
import de.assecutor.votianlt.model.DeliveryStation;
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobServiceSelection;
|
import de.assecutor.votianlt.model.JobServiceSelection;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
@@ -37,9 +35,7 @@ import de.assecutor.votianlt.repository.TaskTemplateRepository;
|
|||||||
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
|
||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SessionAuthenticationService;
|
import de.assecutor.votianlt.security.SessionAuthenticationService;
|
||||||
import java.io.IOException;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
@@ -54,7 +50,6 @@ import java.util.Set;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -68,7 +63,6 @@ public class DemoModeService {
|
|||||||
public static final String DEMO_PASSWORD = "demo";
|
public static final String DEMO_PASSWORD = "demo";
|
||||||
private static final String DEMO_FIRST_NAME = "Demo";
|
private static final String DEMO_FIRST_NAME = "Demo";
|
||||||
private static final String DEMO_LAST_NAME = "Benutzer";
|
private static final String DEMO_LAST_NAME = "Benutzer";
|
||||||
private static final String DEMO_INVOICE_TEMPLATE_RESOURCE = "templates/demo_invoice_template_default.json";
|
|
||||||
|
|
||||||
private final DemoSessionRegistry demoSessionRegistry;
|
private final DemoSessionRegistry demoSessionRegistry;
|
||||||
private final SessionAuthenticationService sessionAuthenticationService;
|
private final SessionAuthenticationService sessionAuthenticationService;
|
||||||
@@ -194,7 +188,6 @@ public class DemoModeService {
|
|||||||
List<Service> services = seedDemoServices(demoUser);
|
List<Service> services = seedDemoServices(demoUser);
|
||||||
List<Customer> customers = seedDemoCustomers(demoUser);
|
List<Customer> customers = seedDemoCustomers(demoUser);
|
||||||
seedDemoJobs(demoUser, customers, services);
|
seedDemoJobs(demoUser, customers, services);
|
||||||
seedDemoInvoiceTemplate(demoUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
|
private void cleanupDemoAppUserData(List<AppUser> demoAppUsers) {
|
||||||
@@ -441,23 +434,6 @@ public class DemoModeService {
|
|||||||
return selection;
|
return selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void seedDemoInvoiceTemplate(User demoUser) {
|
|
||||||
InvoiceTemplate template = new InvoiceTemplate(demoUser.getId().toHexString(), "Einfach",
|
|
||||||
createDemoInvoiceTemplateData());
|
|
||||||
invoiceTemplateRepository.save(template);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String createDemoInvoiceTemplateData() {
|
|
||||||
try {
|
|
||||||
String templateJson = new String(
|
|
||||||
new ClassPathResource(DEMO_INVOICE_TEMPLATE_RESOURCE).getInputStream().readAllBytes(),
|
|
||||||
StandardCharsets.UTF_8);
|
|
||||||
return new ObjectMapper().writeValueAsString(templateJson);
|
|
||||||
} catch (IOException ex) {
|
|
||||||
throw new IllegalStateException("Could not load demo invoice template", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyCustomerToPickup(Job job, Customer customer) {
|
private void applyCustomerToPickup(Job job, Customer customer) {
|
||||||
if (job == null || customer == null) {
|
if (job == null || customer == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.assecutor.votianlt.service;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
@@ -20,6 +21,8 @@ import org.springframework.scheduling.annotation.Async;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -35,7 +38,8 @@ public class EmailService {
|
|||||||
private String smtpUsername;
|
private String smtpUsername;
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy) {
|
public void sendTaskCompletionNotification(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||||
|
String taskDescription) {
|
||||||
try {
|
try {
|
||||||
// Load job to get context information
|
// Load job to get context information
|
||||||
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
Optional<Job> jobOpt = jobRepository.findById(jobId);
|
||||||
@@ -62,7 +66,7 @@ public class EmailService {
|
|||||||
String completedByName = resolveCompletedByName(job, completedBy);
|
String completedByName = resolveCompletedByName(job, completedBy);
|
||||||
|
|
||||||
// Send email
|
// Send email
|
||||||
sendEmail(user, job, taskType, completedByName);
|
sendEmail(user, job, taskType, completedByName, taskDescription);
|
||||||
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
|
log.info("Task completion notification sent to {} for job {} task {}", user.getEmail(), job.getJobNumber(),
|
||||||
taskId);
|
taskId);
|
||||||
|
|
||||||
@@ -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();
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
message.setFrom(smtpUsername);
|
message.setFrom(smtpUsername);
|
||||||
message.setTo(user.getEmail());
|
message.setTo(user.getEmail());
|
||||||
message.setSubject(
|
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 fullName = buildFullName(user);
|
||||||
String taskTypeName = getTaskTypeDisplayName(taskType);
|
String taskTypeName = getTaskTypeDisplayName(taskType);
|
||||||
@@ -85,11 +89,14 @@ public class EmailService {
|
|||||||
StringBuilder body = new StringBuilder();
|
StringBuilder body = new StringBuilder();
|
||||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||||
body.append("eine Aufgabe wurde von ").append(completedByName).append(" abgeschlossen:\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) {
|
if (job.getDeliveryCompany() != null) {
|
||||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
||||||
}
|
}
|
||||||
body.append("Aufgabe: ").append(taskTypeName).append("\n");
|
body.append("Aufgabe: ").append(taskTypeName).append("\n");
|
||||||
|
if (taskDescription != null && !taskDescription.isBlank()) {
|
||||||
|
body.append("Beschreibung: ").append(taskDescription.strip()).append("\n");
|
||||||
|
}
|
||||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
body.append("Abgeschlossen von: ").append(completedByName).append("\n\n");
|
||||||
|
|
||||||
String deliveryCities = job.getDeliveryCitiesDisplay();
|
String deliveryCities = job.getDeliveryCitiesDisplay();
|
||||||
@@ -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
|
@Async
|
||||||
public void sendJobCompletionNotification(ObjectId jobId, String completedBy) {
|
public void sendJobCompletionNotification(ObjectId jobId, String completedBy) {
|
||||||
try {
|
try {
|
||||||
@@ -236,7 +306,7 @@ public class EmailService {
|
|||||||
message.setFrom(smtpUsername);
|
message.setFrom(smtpUsername);
|
||||||
message.setTo(user.getEmail());
|
message.setTo(user.getEmail());
|
||||||
message.setSubject(
|
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);
|
String fullName = buildFullName(user);
|
||||||
|
|
||||||
@@ -246,8 +316,8 @@ public class EmailService {
|
|||||||
|
|
||||||
StringBuilder body = new StringBuilder();
|
StringBuilder body = new StringBuilder();
|
||||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||||
body.append("alle Aufgaben für den folgenden Job wurden erfolgreich abgeschlossen:\n\n");
|
body.append("alle Aufgaben für den folgenden Auftrag wurden erfolgreich 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) {
|
if (job.getDeliveryCompany() != null) {
|
||||||
body.append("Kunde: ").append(job.getDeliveryCompany()).append("\n");
|
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("Anzahl erledigter Aufgaben: ").append(taskCount).append("\n");
|
||||||
body.append("Abgeschlossen von: ").append(completedByName).append("\n\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("Mit freundlichen Grüßen,\n");
|
||||||
body.append("Ihr Votianlt-System");
|
body.append("Ihr Votianlt-System");
|
||||||
|
|
||||||
@@ -325,7 +395,7 @@ public class EmailService {
|
|||||||
message.setFrom(smtpUsername);
|
message.setFrom(smtpUsername);
|
||||||
message.setTo(user.getEmail());
|
message.setTo(user.getEmail());
|
||||||
message.setSubject(
|
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);
|
String fullName = buildFullName(user);
|
||||||
|
|
||||||
@@ -335,8 +405,8 @@ public class EmailService {
|
|||||||
|
|
||||||
StringBuilder body = new StringBuilder();
|
StringBuilder body = new StringBuilder();
|
||||||
body.append("Hallo ").append(fullName).append(",\n\n");
|
body.append("Hallo ").append(fullName).append(",\n\n");
|
||||||
body.append("ein neuer Job wurde erfolgreich erstellt:\n\n");
|
body.append("ein neuer Auftrag wurde erfolgreich erstellt:\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.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
if (job.getCustomerSelection() != null && !job.getCustomerSelection().isBlank()) {
|
||||||
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
body.append("Auftraggeber: ").append(job.getCustomerSelection()).append("\n");
|
||||||
@@ -374,7 +444,7 @@ public class EmailService {
|
|||||||
: "Unbekannt")
|
: "Unbekannt")
|
||||||
.append("\n\n");
|
.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("Mit freundlichen Grüßen,\n");
|
||||||
body.append("Ihr Votianlt-System");
|
body.append("Ihr Votianlt-System");
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
package de.assecutor.votianlt.service;
|
package de.assecutor.votianlt.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
import de.assecutor.votianlt.model.InvoiceTemplate;
|
||||||
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
|
import de.assecutor.votianlt.repository.InvoiceTemplateRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InvoiceTemplateService {
|
public class InvoiceTemplateService {
|
||||||
|
|
||||||
|
public static final String CUSTOMER_DEFAULT_USER_ID = "CUSTOMER_DEFAULT";
|
||||||
|
|
||||||
private final InvoiceTemplateRepository invoiceTemplateRepository;
|
private final InvoiceTemplateRepository invoiceTemplateRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +43,49 @@ public class InvoiceTemplateService {
|
|||||||
return invoiceTemplateRepository.findByUserId(userId);
|
return invoiceTemplateRepository.findByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer invoices use the neutral default if no usable personal template is
|
||||||
|
* stored. Reading the default never creates or overwrites a database record.
|
||||||
|
*/
|
||||||
|
public InvoiceTemplate getCustomerTemplateByUserId(String userId) {
|
||||||
|
if (userId == null || userId.isBlank() || "SYSTEM".equals(userId) || CUSTOMER_DEFAULT_USER_ID.equals(userId)) {
|
||||||
|
throw new IllegalArgumentException("A customer user ID is required");
|
||||||
|
}
|
||||||
|
return invoiceTemplateRepository.findByUserId(userId)
|
||||||
|
.filter(template -> hasTemplateElements(template.getTemplateData()))
|
||||||
|
.orElseGet(() -> invoiceTemplateRepository.findByUserId(CUSTOMER_DEFAULT_USER_ID)
|
||||||
|
.filter(template -> hasTemplateElements(template.getTemplateData()))
|
||||||
|
// Saving the customer's edits must never change the shared default.
|
||||||
|
.map(template -> new InvoiceTemplate(userId, template.getName(), template.getTemplateData()))
|
||||||
|
.orElseGet(() -> loadBundledCustomerDefault(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InvoiceTemplate loadBundledCustomerDefault(String userId) {
|
||||||
|
try (var input = new ClassPathResource("templates/customer_invoice_template_neutral.json").getInputStream()) {
|
||||||
|
String json = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
return new InvoiceTemplate(userId, "Neutral", new ObjectMapper().writeValueAsString(json));
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Could not load neutral customer invoice template", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasTemplateElements(String templateData) {
|
||||||
|
if (templateData == null || templateData.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
JsonNode data = mapper.readTree(templateData);
|
||||||
|
// Templates can be stored as an object or as a JSON-encoded string.
|
||||||
|
if (data != null && data.isTextual()) {
|
||||||
|
data = mapper.readTree(data.textValue());
|
||||||
|
}
|
||||||
|
return data != null && data.path("elements").isArray() && !data.path("elements").isEmpty();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a template exists for a user
|
* Check if a template exists for a user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import de.assecutor.votianlt.model.Job;
|
|||||||
import de.assecutor.votianlt.model.JobHistory;
|
import de.assecutor.votianlt.model.JobHistory;
|
||||||
import de.assecutor.votianlt.model.JobHistoryType;
|
import de.assecutor.votianlt.model.JobHistoryType;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
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.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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.bson.types.ObjectId;
|
import org.bson.types.ObjectId;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -18,10 +26,20 @@ public class JobHistoryService {
|
|||||||
|
|
||||||
private final JobHistoryRepository jobHistoryRepository;
|
private final JobHistoryRepository jobHistoryRepository;
|
||||||
private final ObjectMapper objectMapper;
|
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.jobHistoryRepository = jobHistoryRepository;
|
||||||
this.objectMapper = objectMapper;
|
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) {
|
public void logJobCreation(Job job, String createdBy) {
|
||||||
try {
|
try {
|
||||||
JobHistory history = new JobHistory(job.getId(), "Job erstellt",
|
JobHistory history = new JobHistory(job.getId(), "Auftrag erstellt",
|
||||||
"Neuer Job wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
"Neuer Auftrag wurde erstellt: " + (job.getJobNumber() != null ? job.getJobNumber() : "Ohne Nummer"),
|
||||||
createdBy, JobHistoryType.CREATE, null, "Job erstellt");
|
createdBy, JobHistoryType.CREATE, null, "Auftrag erstellt");
|
||||||
|
|
||||||
if (job.getDeliveryCompany() != null) {
|
if (job.getDeliveryCompany() != null) {
|
||||||
history.setDetails("Kunde: " + job.getDeliveryCompany());
|
history.setDetails("Kunde: " + job.getDeliveryCompany());
|
||||||
@@ -70,7 +88,7 @@ public class JobHistoryService {
|
|||||||
try {
|
try {
|
||||||
String description = generateUpdateDescription(oldJob, newJob);
|
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),
|
description, changedBy, JobHistoryType.UPDATE, serializeJobForComparison(oldJob),
|
||||||
serializeJobForComparison(newJob));
|
serializeJobForComparison(newJob));
|
||||||
|
|
||||||
@@ -93,6 +111,11 @@ public class JobHistoryService {
|
|||||||
*/
|
*/
|
||||||
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
public void logTaskCompletion(ObjectId jobId, String taskType, String taskId, String completedBy,
|
||||||
String taskDisplayName, String extraDataSummary) {
|
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 {
|
try {
|
||||||
String taskName = taskDisplayName != null ? taskDisplayName : taskType;
|
String taskName = taskDisplayName != null ? taskDisplayName : taskType;
|
||||||
String description = String.format("Aufgabe abgeschlossen: %s", taskName);
|
String description = String.format("Aufgabe abgeschlossen: %s", taskName);
|
||||||
@@ -101,8 +124,10 @@ public class JobHistoryService {
|
|||||||
description += " - " + extraDataSummary;
|
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");
|
JobHistoryType.TASK_COMPLETED, "In Bearbeitung", "Abgeschlossen");
|
||||||
|
history.setTaskId(ObjectId.isValid(taskId) ? new ObjectId(taskId) : null);
|
||||||
|
history.setTaskDescription(taskDescription);
|
||||||
|
|
||||||
// Detaillierte Informationen in details speichern
|
// Detaillierte Informationen in details speichern
|
||||||
StringBuilder details = new StringBuilder();
|
StringBuilder details = new StringBuilder();
|
||||||
@@ -132,11 +157,11 @@ public class JobHistoryService {
|
|||||||
try {
|
try {
|
||||||
String description;
|
String description;
|
||||||
if (oldAssignee == null && newAssignee != null) {
|
if (oldAssignee == null && newAssignee != null) {
|
||||||
description = "Job zugewiesen an: " + newAssignee;
|
description = "Auftrag zugewiesen an: " + newAssignee;
|
||||||
} else if (oldAssignee != null && newAssignee == null) {
|
} else if (oldAssignee != null && newAssignee == null) {
|
||||||
description = "Job-Zuweisung entfernt von: " + oldAssignee;
|
description = "Auftragszuweisung entfernt von: " + oldAssignee;
|
||||||
} else {
|
} 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,
|
JobHistory history = new JobHistory(job.getId(), "Zuweisung geändert", description, changedBy,
|
||||||
@@ -177,6 +202,85 @@ public class JobHistoryService {
|
|||||||
return jobHistoryRepository.countByJobId(jobId);
|
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
|
// Helper methods
|
||||||
|
|
||||||
private String formatStatus(JobStatus status) {
|
private String formatStatus(JobStatus status) {
|
||||||
@@ -197,7 +301,7 @@ public class JobHistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String generateUpdateDescription(Job oldJob, Job newJob) {
|
private String generateUpdateDescription(Job oldJob, Job newJob) {
|
||||||
StringBuilder description = new StringBuilder("Job-Daten aktualisiert");
|
StringBuilder description = new StringBuilder("Auftragsdaten aktualisiert");
|
||||||
|
|
||||||
if (oldJob == null) {
|
if (oldJob == null) {
|
||||||
return description.toString();
|
return description.toString();
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ page.title.statistics=Statistiken
|
|||||||
page.title.password.forget=Passwort zurücksetzen
|
page.title.password.forget=Passwort zurücksetzen
|
||||||
page.title.invoices=Rechnungen
|
page.title.invoices=Rechnungen
|
||||||
page.title.appusers=App-Nutzer
|
page.title.appusers=App-Nutzer
|
||||||
page.title.job.history=Job Historie
|
page.title.job.history=Auftrags-Historie
|
||||||
page.title.message.history=Nachrichtenverlauf
|
page.title.message.history=Nachrichtenverlauf
|
||||||
page.title.myinvoices=Rechnungen
|
page.title.myinvoices=Rechnungen
|
||||||
page.title.job.create=Neuen Auftrag anlegen
|
page.title.job.create=Neuen Auftrag anlegen
|
||||||
@@ -583,11 +583,11 @@ addjob.validation.address.correct=Adresse korrigieren
|
|||||||
|
|
||||||
# Job Summary
|
# Job Summary
|
||||||
jobsummary.title=Zusammenfassung
|
jobsummary.title=Zusammenfassung
|
||||||
jobsummary.error.noid=Keine Job-ID angegeben
|
jobsummary.error.noid=Keine Auftrags-ID angegeben
|
||||||
jobsummary.error.invalidid=Ungültige Job-ID Format: {0}
|
jobsummary.error.invalidid=Ungültiges Format der Auftrags-ID: {0}
|
||||||
jobsummary.error.notfound=Job mit ID {0} nicht gefunden
|
jobsummary.error.notfound=Auftrag mit ID {0} nicht gefunden
|
||||||
jobsummary.button.sendmessage=Nachricht senden
|
jobsummary.button.sendmessage=Nachricht senden
|
||||||
jobsummary.button.jobhistory=Job Historie
|
jobsummary.button.jobhistory=Auftrags-Historie
|
||||||
jobsummary.button.complete=Auftrag manuell abschließen
|
jobsummary.button.complete=Auftrag manuell abschließen
|
||||||
jobsummary.dialog.complete.title=Auftrag abschließen
|
jobsummary.dialog.complete.title=Auftrag abschließen
|
||||||
jobsummary.dialog.complete.text=Möchten Sie den Auftrag {0} manuell 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
|
# Create Invoice
|
||||||
createinvoice.title=Rechnung erstellen \u2013 Auftrag {0}
|
createinvoice.title=Rechnung erstellen \u2013 Auftrag {0}
|
||||||
createinvoice.error.invalidid=Ungültige Job-ID
|
createinvoice.error.invalidid=Ungültige Auftrags-ID
|
||||||
createinvoice.error.notfound=Job nicht gefunden
|
createinvoice.error.notfound=Auftrag nicht gefunden
|
||||||
createinvoice.button.create=Rechnung erstellen
|
createinvoice.button.create=Rechnung erstellen
|
||||||
createinvoice.section.job=Auftragsdetails
|
createinvoice.section.job=Auftragsdetails
|
||||||
createinvoice.section.route=Streckeninfo
|
createinvoice.section.route=Streckeninfo
|
||||||
@@ -986,7 +986,7 @@ csv.header.customer=Auftraggeber
|
|||||||
csv.header.jobnumber=Auftragsnummer
|
csv.header.jobnumber=Auftragsnummer
|
||||||
csv.header.jobdate=Auftragsdatum
|
csv.header.jobdate=Auftragsdatum
|
||||||
csv.header.destination=Zielort
|
csv.header.destination=Zielort
|
||||||
csv.filename=jobs.csv
|
csv.filename=auftraege.csv
|
||||||
|
|
||||||
# DatePicker I18n
|
# DatePicker I18n
|
||||||
datepicker.month.januar=Januar
|
datepicker.month.januar=Januar
|
||||||
@@ -1022,15 +1022,15 @@ jobhistory.status.pickedup=Abgeholt
|
|||||||
jobhistory.status.intransit=Unterwegs
|
jobhistory.status.intransit=Unterwegs
|
||||||
jobhistory.status.delivered=Zugestellt
|
jobhistory.status.delivered=Zugestellt
|
||||||
jobhistory.image.alt=Vergrößertes Foto
|
jobhistory.image.alt=Vergrößertes Foto
|
||||||
jobhistory.title=Jobhistorie
|
jobhistory.title=Auftrags-Historie
|
||||||
jobhistory.header=Jobhistorie für {0}
|
jobhistory.header=Auftrags-Historie für {0}
|
||||||
jobhistory.info.customer=Kunde: {0}
|
jobhistory.info.customer=Kunde: {0}
|
||||||
jobhistory.info.createdat=Erstellt am: {0}
|
jobhistory.info.createdat=Erstellt am: {0}
|
||||||
jobhistory.info.status=Status: {0}
|
jobhistory.info.status=Status: {0}
|
||||||
jobhistory.count={0} Einträge in der Historie
|
jobhistory.count={0} Einträge in der Historie
|
||||||
jobhistory.changedby=Geändert von: {0}
|
jobhistory.changedby=Erstellt von: {0}
|
||||||
jobhistory.entry.create.reason=Job erstellt
|
jobhistory.entry.create.reason=Auftrag erstellt
|
||||||
jobhistory.entry.create.description=Neuer Job wurde erstellt: {0}
|
jobhistory.entry.create.description=Neuer Auftrag wurde erstellt: {0}
|
||||||
|
|
||||||
# Version
|
# Version
|
||||||
version.label=Version
|
version.label=Version
|
||||||
|
|||||||
@@ -870,7 +870,7 @@ jobhistory.info.customer=Klient: {0}
|
|||||||
jobhistory.info.createdat=Loodud: {0}
|
jobhistory.info.createdat=Loodud: {0}
|
||||||
jobhistory.info.status=Olek: {0}
|
jobhistory.info.status=Olek: {0}
|
||||||
jobhistory.count={0} kirjet ajaloos
|
jobhistory.count={0} kirjet ajaloos
|
||||||
jobhistory.changedby=Muutnud: {0}
|
jobhistory.changedby=Loonud: {0}
|
||||||
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
|
jobhistory.entry.create.reason=T\u00f6\u00f6 loodud
|
||||||
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
|
jobhistory.entry.create.description=Uus t\u00f6\u00f6 loodi: {0}
|
||||||
version.label=Versioon
|
version.label=Versioon
|
||||||
|
|||||||
@@ -1028,7 +1028,7 @@ jobhistory.info.customer=Customer: {0}
|
|||||||
jobhistory.info.createdat=Created on: {0}
|
jobhistory.info.createdat=Created on: {0}
|
||||||
jobhistory.info.status=Status: {0}
|
jobhistory.info.status=Status: {0}
|
||||||
jobhistory.count={0} entries in history
|
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.reason=Job Created
|
||||||
jobhistory.entry.create.description=New job was created: {0}
|
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.createdat=Creado el: {0}
|
||||||
jobhistory.info.status=Estado: {0}
|
jobhistory.info.status=Estado: {0}
|
||||||
jobhistory.count={0} entradas en el historial
|
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.reason=Pedido creado
|
||||||
jobhistory.entry.create.description=Se ha creado un nuevo pedido: {0}
|
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.createdat=Cr\u00e9\u00e9 le : {0}
|
||||||
jobhistory.info.status=Statut : {0}
|
jobhistory.info.status=Statut : {0}
|
||||||
jobhistory.count={0} entr\u00e9es dans l'historique
|
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.reason=Mission cr\u00e9\u00e9e
|
||||||
jobhistory.entry.create.description=Nouvelle mission cr\u00e9\u00e9e : {0}
|
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.createdat=Sukurta: {0}
|
||||||
jobhistory.info.status=Būsena: {0}
|
jobhistory.info.status=Būsena: {0}
|
||||||
jobhistory.count={0} įrašų istorijoje
|
jobhistory.count={0} įrašų istorijoje
|
||||||
jobhistory.changedby=Pakeitė: {0}
|
jobhistory.changedby=Sukūrė: {0}
|
||||||
jobhistory.entry.create.reason=Užsakymas sukurtas
|
jobhistory.entry.create.reason=Užsakymas sukurtas
|
||||||
jobhistory.entry.create.description=Sukurtas naujas užsakymas: {0}
|
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.createdat=Izveidots: {0}
|
||||||
jobhistory.info.status=Statuss: {0}
|
jobhistory.info.status=Statuss: {0}
|
||||||
jobhistory.count={0} ieraksti vēsturē
|
jobhistory.count={0} ieraksti vēsturē
|
||||||
jobhistory.changedby=Mainīja: {0}
|
jobhistory.changedby=Izveidoja: {0}
|
||||||
jobhistory.entry.create.reason=Uzdevums izveidots
|
jobhistory.entry.create.reason=Uzdevums izveidots
|
||||||
jobhistory.entry.create.description=Izveidots jauns uzdevums: {0}
|
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.createdat=Utworzono dnia: {0}
|
||||||
jobhistory.info.status=Status: {0}
|
jobhistory.info.status=Status: {0}
|
||||||
jobhistory.count={0} wpis\u00f3w w historii
|
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.reason=Zlecenie utworzone
|
||||||
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
|
jobhistory.entry.create.description=Utworzono nowe zlecenie: {0}
|
||||||
|
|
||||||
|
|||||||
@@ -959,7 +959,7 @@ jobhistory.info.customer=Клиент: {0}
|
|||||||
jobhistory.info.createdat=Создано: {0}
|
jobhistory.info.createdat=Создано: {0}
|
||||||
jobhistory.info.status=Статус: {0}
|
jobhistory.info.status=Статус: {0}
|
||||||
jobhistory.count={0} записей в истории
|
jobhistory.count={0} записей в истории
|
||||||
jobhistory.changedby=Изменено: {0}
|
jobhistory.changedby=Создано: {0}
|
||||||
jobhistory.entry.create.reason=Заказ создан
|
jobhistory.entry.create.reason=Заказ создан
|
||||||
jobhistory.entry.create.description=Создан новый заказ: {0}
|
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.createdat=Olu\u015fturulma Tarihi: {0}
|
||||||
jobhistory.info.status=Durum: {0}
|
jobhistory.info.status=Durum: {0}
|
||||||
jobhistory.count=Ge\u00e7mi\u015fte {0} kay\u0131t
|
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.reason=\u0130\u015f olu\u015fturuldu
|
||||||
jobhistory.entry.create.description=Yeni i\u015f olu\u015fturuldu: {0}
|
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.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -10,7 +11,6 @@ import static org.mockito.Mockito.when;
|
|||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.JobStatus;
|
import de.assecutor.votianlt.model.JobStatus;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.InvoiceTemplate;
|
|
||||||
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
import de.assecutor.votianlt.pages.domain.CustomerRepository;
|
||||||
import de.assecutor.votianlt.repository.AppUserRepository;
|
import de.assecutor.votianlt.repository.AppUserRepository;
|
||||||
import de.assecutor.votianlt.repository.BarcodeRepository;
|
import de.assecutor.votianlt.repository.BarcodeRepository;
|
||||||
@@ -90,8 +90,6 @@ class DemoModeServiceTest {
|
|||||||
private ArgumentCaptor<List<Job>> jobsCaptor;
|
private ArgumentCaptor<List<Job>> jobsCaptor;
|
||||||
@Captor
|
@Captor
|
||||||
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
|
private ArgumentCaptor<List<de.assecutor.votianlt.model.Service>> servicesCaptor;
|
||||||
@Captor
|
|
||||||
private ArgumentCaptor<InvoiceTemplate> invoiceTemplateCaptor;
|
|
||||||
|
|
||||||
private DemoSessionRegistry demoSessionRegistry;
|
private DemoSessionRegistry demoSessionRegistry;
|
||||||
private DemoModeService demoModeService;
|
private DemoModeService demoModeService;
|
||||||
@@ -127,17 +125,8 @@ class DemoModeServiceTest {
|
|||||||
assertThat(prepared).isTrue();
|
assertThat(prepared).isTrue();
|
||||||
verify(serviceRepository).saveAll(servicesCaptor.capture());
|
verify(serviceRepository).saveAll(servicesCaptor.capture());
|
||||||
assertThat(servicesCaptor.getValue()).hasSize(2);
|
assertThat(servicesCaptor.getValue()).hasSize(2);
|
||||||
verify(invoiceTemplateRepository).save(invoiceTemplateCaptor.capture());
|
// Demo customers use the same neutral default as all other customers.
|
||||||
assertThat(invoiceTemplateCaptor.getValue().getUserId()).isEqualTo(demoUser.getId().toHexString());
|
verify(invoiceTemplateRepository, never()).save(any());
|
||||||
assertThat(invoiceTemplateCaptor.getValue().getTemplateData())
|
|
||||||
.contains("elements")
|
|
||||||
.contains("services.list")
|
|
||||||
.contains("invoice.gross_total")
|
|
||||||
.contains("frei angepasst werden")
|
|
||||||
.contains("textAlign")
|
|
||||||
.contains("center")
|
|
||||||
.contains("fontSize")
|
|
||||||
.contains("20");
|
|
||||||
verify(jobRepository).saveAll(jobsCaptor.capture());
|
verify(jobRepository).saveAll(jobsCaptor.capture());
|
||||||
assertThat(jobsCaptor.getValue()).hasSize(2);
|
assertThat(jobsCaptor.getValue()).hasSize(2);
|
||||||
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
|
assertThat(jobsCaptor.getValue()).extracting(Job::getJobNumber)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.assecutor.votianlt.service;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import de.assecutor.votianlt.model.AppUser;
|
import de.assecutor.votianlt.model.AppUser;
|
||||||
|
import de.assecutor.votianlt.model.DeliveryStation;
|
||||||
import de.assecutor.votianlt.model.Job;
|
import de.assecutor.votianlt.model.Job;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
import de.assecutor.votianlt.model.task.ConfirmationTask;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Captor;
|
import org.mockito.Captor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
@@ -55,7 +60,7 @@ class EmailServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendTaskCompletionNotificationUsesAssignedAppUserName() {
|
void sendTaskCompletionNotificationIncludesDescriptionAndAssignedAppUserName() {
|
||||||
User webUser = createWebUser();
|
User webUser = createWebUser();
|
||||||
AppUser assignedAppUser = createAssignedAppUser();
|
AppUser assignedAppUser = createAssignedAppUser();
|
||||||
Job job = createJob(webUser, assignedAppUser);
|
Job job = createJob(webUser, assignedAppUser);
|
||||||
@@ -64,13 +69,36 @@ class EmailServiceTest {
|
|||||||
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||||
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
|
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
|
||||||
|
|
||||||
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored");
|
emailService.sendTaskCompletionNotification(job.getId(), "PHOTO", "task-1", "ignored",
|
||||||
|
" Ware am Seiteneingang fotografieren.\nAuch die Verpackung dokumentieren. ");
|
||||||
|
|
||||||
verify(mailSender).send(mailCaptor.capture());
|
verify(mailSender).send(mailCaptor.capture());
|
||||||
String body = mailCaptor.getValue().getText();
|
String body = mailCaptor.getValue().getText();
|
||||||
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
|
assertThat(body).contains("eine Aufgabe wurde von Max Mustermann abgeschlossen:");
|
||||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||||
|
assertThat(body).contains("Auftrag: ").doesNotContain("Job:");
|
||||||
|
assertThat(body).contains("Aufgabe: Foto-Aufgabe\nBeschreibung: Ware am Seiteneingang fotografieren.\n"
|
||||||
|
+ "Auch die Verpackung dokumentieren.\nAbgeschlossen von: Max Mustermann");
|
||||||
|
assertThat(mailCaptor.getValue().getSubject()).isEqualTo("Aufgabe abgeschlossen - JOB-2026-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@NullAndEmptySource
|
||||||
|
@ValueSource(strings = { " \n\t " })
|
||||||
|
void taskCompletionEmailWithoutDescriptionStillSends(String description) {
|
||||||
|
User webUser = createWebUser();
|
||||||
|
AppUser assignedAppUser = createAssignedAppUser();
|
||||||
|
Job job = createJob(webUser, assignedAppUser);
|
||||||
|
when(jobRepository.findById(job.getId())).thenReturn(Optional.of(job));
|
||||||
|
when(userRepository.findById(webUser.getId())).thenReturn(Optional.of(webUser));
|
||||||
|
when(appUserRepository.findById(assignedAppUser.getId())).thenReturn(Optional.of(assignedAppUser));
|
||||||
|
|
||||||
|
emailService.sendTaskCompletionNotification(job.getId(), "CONFIRMATION", "task-1", "ignored", description);
|
||||||
|
|
||||||
|
verify(mailSender).send(mailCaptor.capture());
|
||||||
|
assertThat(mailCaptor.getValue().getText()).contains("Aufgabe: Bestätigung\nAbgeschlossen von: Max Mustermann")
|
||||||
|
.doesNotContain("Beschreibung:", "null");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -93,6 +121,86 @@ class EmailServiceTest {
|
|||||||
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
|
assertThat(body).contains("Anzahl erledigter Aufgaben: 2");
|
||||||
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
assertThat(body).contains("Abgeschlossen von: Max Mustermann");
|
||||||
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
assertThat(body).contains("Hallo Dr. Anna Unternehmer,");
|
||||||
|
assertThat(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() {
|
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