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